Proxy and Reflect
Learn how to intercept and customize JavaScript object operations using Proxy and Reflect.
Proxy and Reflect
Proxy lets you intercept and customize fundamental operations on objects — reading properties, writing properties, calling functions, checking if a property exists, and more. It is a way to wrap an object and put your own code in between.
Reflect is a companion API — it provides the same operations as the proxy traps but in a clean, functional form. It makes it easy to forward operations from a proxy to the original object.
What is a Proxy?
A Proxy wraps an object and lets you intercept operations on it.
const target = { name: "Ali", age: 22 };
const proxy = new Proxy(target, {
get(target, property) {
console.log(`Getting: ${property}`);
return target[property];
}
});
console.log(proxy.name);
// Getting: name
// Ali
console.log(proxy.age);
// Getting: age
// 22target is the original object. The second argument is the handler — an object containing traps — functions that intercept specific operations.
Syntax
const proxy = new Proxy(target, handler);target— the object being wrappedhandler— an object with trap methods- Each trap corresponds to one JavaScript operation
The Most Important Traps
| Trap | Intercepts |
|---|---|
get(target, prop) | Reading a property |
set(target, prop, value) | Writing a property |
has(target, prop) | in operator |
deleteProperty(target, prop) | delete operator |
apply(target, thisArg, args) | Function calls |
construct(target, args) | new operator |
ownKeys(target) | Object.keys(), for...in |
get Trap — Intercept Property Reads
const user = { name: "Ali", age: 22 };
const proxy = new Proxy(user, {
get(target, property) {
if (property in target) {
return target[property];
}
return `Property "${property}" does not exist`;
}
});
console.log(proxy.name); // Ali
console.log(proxy.age); // 22
console.log(proxy.email); // Property "email" does not existDefault values for missing properties
function withDefaults(obj, defaults) {
return new Proxy(obj, {
get(target, prop) {
return prop in target ? target[prop] : defaults[prop];
}
});
}
const config = withDefaults(
{ theme: "dark" },
{ theme: "light", language: "en", fontSize: 14, debug: false }
);
console.log(config.theme); // dark — from target
console.log(config.language); // en — from defaults
console.log(config.fontSize); // 14 — from defaults
console.log(config.debug); // false — from defaultsset Trap — Intercept Property Writes
function createValidatedObject(schema) {
return new Proxy({}, {
set(target, prop, value) {
if (schema[prop]) {
const { type, min, max, required } = schema[prop];
if (type && typeof value !== type) {
throw new TypeError(`${prop} must be of type ${type}`);
}
if (typeof value === "number") {
if (min !== undefined && value < min) {
throw new RangeError(`${prop} must be at least ${min}`);
}
if (max !== undefined && value > max) {
throw new RangeError(`${prop} must be at most ${max}`);
}
}
if (typeof value === "string" && min !== undefined && value.length < min) {
throw new RangeError(`${prop} must be at least ${min} characters`);
}
}
target[prop] = value;
return true; // must return true to indicate success
}
});
}
const user = createValidatedObject({
name: { type: "string", min: 2 },
age: { type: "number", min: 0, max: 150 },
email: { type: "string" }
});
user.name = "Ali"; // ✅
user.age = 22; // ✅
user.name = "A"; // ❌ RangeError: name must be at least 2 characters
user.age = -5; // ❌ RangeError: age must be at least 0
user.age = "old"; // ❌ TypeError: age must be of type numberThe set trap must return true to indicate the assignment succeeded. If it returns false (or nothing), JavaScript throws a TypeError in strict mode.
has Trap — Intercept the in Operator
const range = new Proxy({ min: 1, max: 100 }, {
has(target, prop) {
const num = Number(prop);
return num >= target.min && num <= target.max;
}
});
console.log(50 in range); // true
console.log(1 in range); // true
console.log(100 in range); // true
console.log(0 in range); // false
console.log(101 in range); // falsedeleteProperty Trap — Intercept delete
function createProtectedObject(obj, protectedKeys) {
return new Proxy(obj, {
deleteProperty(target, prop) {
if (protectedKeys.includes(prop)) {
throw new Error(`Cannot delete protected property: ${prop}`);
}
delete target[prop];
return true;
}
});
}
const config = createProtectedObject(
{ apiUrl: "https://api.example.com", debug: true, version: "1.0" },
["apiUrl", "version"]
);
delete config.debug; // ✅ allowed
delete config.apiUrl; // ❌ Error: Cannot delete protected property: apiUrlapply Trap — Intercept Function Calls
The apply trap intercepts calls to a function proxy.
function sum(...numbers) {
return numbers.reduce((total, n) => total + n, 0);
}
const loggedSum = new Proxy(sum, {
apply(target, thisArg, args) {
console.log(`Calling sum with args: [${args}]`);
const result = target.apply(thisArg, args);
console.log(`Result: ${result}`);
return result;
}
});
loggedSum(1, 2, 3);
// Calling sum with args: [1,2,3]
// Result: 6
loggedSum(10, 20);
// Calling sum with args: [10,20]
// Result: 30Generic function logger
function withLogging(fn, name = fn.name) {
return new Proxy(fn, {
apply(target, thisArg, args) {
console.log(`[${name}] called with:`, args);
const result = Reflect.apply(target, thisArg, args);
console.log(`[${name}] returned:`, result);
return result;
}
});
}
const add = (a, b) => a + b;
const loggedAdd = withLogging(add);
loggedAdd(3, 4);
// [add] called with: [3, 4]
// [add] returned: 7Reflect
Reflect provides the default implementations for all proxy trap operations. It mirrors the proxy handler methods exactly — same names, same signatures.
// These are equivalent:
target[prop] ↔ Reflect.get(target, prop)
target[prop] = value ↔ Reflect.set(target, prop, value)
delete target[prop] ↔ Reflect.deleteProperty(target, prop)
prop in target ↔ Reflect.has(target, prop)
new Target(...args) ↔ Reflect.construct(Target, args)
fn.apply(obj, args) ↔ Reflect.apply(fn, obj, args)Why use Reflect?
The main reason — inside a proxy trap, use Reflect to forward the operation to the target with the correct behavior, especially when dealing with inherited properties and this binding.
const user = {
_name: "Ali",
get name() {
return this._name; // 'this' matters here
}
};
// ❌ Without Reflect — this breaks getter
const proxy1 = new Proxy(user, {
get(target, prop) {
return target[prop]; // 'this' inside getter is target, not proxy
}
});
// ✅ With Reflect — preserves correct this binding
const proxy2 = new Proxy(user, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver); // receiver = proxy
}
});receiver is the proxy itself. Reflect.get passes it as this to any getter — so getters on the target work correctly.
The canonical proxy pattern
When you want to intercept an operation and still do the default thing — use Reflect:
const handler = {
get(target, prop, receiver) {
console.log(`get: ${prop}`);
return Reflect.get(target, prop, receiver); // do default get
},
set(target, prop, value, receiver) {
console.log(`set: ${prop} = ${value}`);
return Reflect.set(target, prop, value, receiver); // do default set
},
deleteProperty(target, prop) {
console.log(`delete: ${prop}`);
return Reflect.deleteProperty(target, prop); // do default delete
}
};Real Use — Reactive Data
One of the most powerful uses of Proxy is building reactive data — objects that automatically trigger updates when they change. This is exactly how Vue 3's reactivity system works.
function reactive(obj, onChange) {
return new Proxy(obj, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
// If value is an object — make it reactive too
if (typeof value === "object" && value !== null) {
return reactive(value, onChange);
}
return value;
},
set(target, prop, value, receiver) {
const oldValue = target[prop];
const result = Reflect.set(target, prop, value, receiver);
if (oldValue !== value) {
onChange(prop, oldValue, value);
}
return result;
}
});
}
const state = reactive(
{ user: { name: "Ali", age: 22 }, theme: "light" },
(prop, oldVal, newVal) => {
console.log(`Changed: ${prop} — ${oldVal} → ${newVal}`);
updateUI();
}
);
function updateUI() {
console.log("UI updated!");
}
state.theme = "dark";
// Changed: theme — light → dark
// UI updated!
state.user.name = "Sara";
// Changed: name — Ali → Sara
// UI updated!The proxy intercepts every property write — no matter how deep — and triggers the update. This is a simplified version of Vue 3's reactive core.
Real Use — Read-Only Object
function readOnly(obj) {
return new Proxy(obj, {
set(target, prop) {
throw new TypeError(`Cannot set property "${prop}" — object is read-only`);
},
deleteProperty(target, prop) {
throw new TypeError(`Cannot delete property "${prop}" — object is read-only`);
},
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value === "object" && value !== null) {
return readOnly(value); // nested objects are also read-only
}
return value;
}
});
}
const config = readOnly({
apiUrl: "https://api.example.com",
settings: { theme: "dark", language: "en" }
});
console.log(config.apiUrl); // https://api.example.com
config.apiUrl = "changed"; // ❌ TypeError
delete config.apiUrl; // ❌ TypeError
config.settings.theme = "light"; // ❌ TypeError — nested tooReal Use — API Client With Proxy
function createApiClient(baseUrl) {
return new Proxy({}, {
get(target, resource) {
// proxy.users → creates a resource handler
return new Proxy({}, {
get(_, method) {
// proxy.users.get → returns a function
return async (id, options = {}) => {
const url = id
? `${baseUrl}/${resource}/${id}`
: `${baseUrl}/${resource}`;
const response = await fetch(url, {
method: method.toUpperCase(),
headers: { "Content-Type": "application/json" },
...options
});
if (!response.ok) {
throw new Error(`${method.toUpperCase()} ${url} failed: ${response.status}`);
}
return response.json();
};
}
});
}
});
}
const api = createApiClient("https://jsonplaceholder.typicode.com");
// Magically works for any resource and method
const users = await api.users.get();
const user = await api.users.get(1);
const posts = await api.posts.get();
const post = await api.posts.get(1);
console.log(user.name); // Leanne Graham
console.log(post.title); // sunt aut facere...No manually defined methods for each resource — the Proxy intercepts any property access and creates the right API call dynamically.
Proxy Limitations
// Proxies cannot intercept:
// - Private class fields (#field) — not accessible through normal property access
// - Strict equality comparison (===) — proxy !== target
// - typeof operator
// - WeakMap/WeakSet keys — identity-based, proxy has different identity
class User {
#name;
constructor(name) { this.#name = name; }
getName() { return this.#name; }
}
const user = new User("Ali");
const proxy = new Proxy(user, {});
console.log(proxy.getName()); // ✅ works — calls through proxy
// Private fields accessed through methods work fineSummary
- Proxy wraps an object and intercepts operations through traps —
get,set,has,deleteProperty,apply,construct - Handler — the object containing traps — each trap corresponds to one JavaScript operation
gettrap — intercept property reads — add defaults, logging, virtual propertiessettrap — intercept property writes — validate data, track changes — must returntruehastrap — intercept theinoperator — custom membership checksapplytrap — intercept function calls — logging, timing, memoization- Reflect provides default implementations for all trap operations — same names, same behavior
- Use
Reflect.get(target, prop, receiver)inside traps to preserve correctthisbinding for getters - The canonical pattern — intercept the operation, do something extra, forward to Reflect
- Real uses — validation, reactive data, read-only objects, dynamic APIs, logging
- Proxies cannot intercept private class fields, strict equality, or
typeof