Proxy and Reflect
Mental model: A Proxy is a customs officer standing between an object and everyone who talks to it, and Reflect is the phrase book it uses to pass a request through unchanged.
Level: advanced · about 22 minutes
Every operation you perform on an object is a call into the engine: reading a property is [[Get]], delete is [[Delete]], in is [[HasProperty]]. Normally those are sealed. A Proxy lets you intercept them. You hand it a target and a handler of trap functions, and from then on the language asks your handler first.
const target = { tea: 3 };
const stock = new Proxy(target, {
get(obj, key) {
return key in obj ? obj[key] : 0;
},
});
console.log(stock.tea); // -> 3
console.log(stock.coffee); // -> 0 the trap answered
console.log(target.coffee); // -> undefined, the target never changed
stock.coffee = 5; // no set trap, so this passes straight through
console.log(target.coffee); // -> 5The smallest useful proxy: a default value instead of undefined.
All thirteen traps
| Trap | Fires on | Must return |
|---|---|---|
get(t, key, receiver) | obj.x, obj[x], destructuring | the value |
set(t, key, value, receiver) | obj.x = 1, obj[x] = 1 | true, or false to throw in strict mode |
has(t, key) | key in obj, with | boolean |
deleteProperty(t, key) | delete obj.x | boolean |
apply(t, thisArg, args) | fn(...), fn.call, fn.apply | anything |
construct(t, args, newTarget) | new Fn(...) | an object |
getPrototypeOf(t) | Object.getPrototypeOf, instanceof, __proto__ | object or null |
setPrototypeOf(t, proto) | Object.setPrototypeOf | boolean |
isExtensible(t) | Object.isExtensible | boolean matching the target |
preventExtensions(t) | Object.preventExtensions, freeze, seal | boolean |
getOwnPropertyDescriptor(t, key) | Object.getOwnPropertyDescriptor, Object.keys | descriptor or undefined |
defineProperty(t, key, desc) | Object.defineProperty, Object.assign targets | boolean |
ownKeys(t) | Object.keys, for...in, spread, JSON.stringify | array of strings and symbols |
Reflect: the trap you were about to write by hand
Reflect is a namespace of thirteen functions, one per trap, that perform the default operation. Inside a trap you almost always want "do the normal thing, but also do mine", and Reflect is how you say the normal thing without guessing.
Guessing the default
get(target, key) {
return target[key];
}
set(target, key, value) {
target[key] = value;
return true; // even if it failed
}
deleteProperty(target, key) {
delete target[key];
return true;
}
Forwarding with Reflect
get(target, key, receiver) {
return Reflect.get(target, key, receiver);
}
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver);
}
deleteProperty(target, key) {
return Reflect.deleteProperty(target, key);
}The left column drops the receiver, so getters see the wrong this, and it reports success it did not verify. Reflect methods return the boolean the trap is supposed to return, and they take the receiver.
const base = {
first: 'Ada',
last: 'Lovelace',
get full() {
return `${this.first} ${this.last}`;
},
};
const upper = new Proxy(base, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver); // receiver = the proxy
return typeof value === 'string' ? value.toUpperCase() : value;
},
});
console.log(upper.full); // -> ADA LOVELACE
// Without the receiver, `this` inside the getter is the raw target,
// so first and last are never uppercased and only the final result is:
const naive = new Proxy(base, {
get(target, key) {
const value = target[key];
return typeof value === 'string' ? value.toUpperCase() : value;
},
});
console.log(naive.full); // -> ADA LOVELACE (uppercased once, at the end)Why the receiver matters: a getter that reads other properties.
`Reflect.get(t, k, receiver)`- the only way to invoke a getter with a different
this `Reflect.ownKeys(t)`- strings and symbols together, the honest key list
`Reflect.apply(fn, thisArg, args)`- a safer
fn.applythat cannot be shadowed by the object `Reflect.construct(Fn, args, NewTarget)`newwith a swappable prototype source`Reflect.has(t, k)`inas a function, so it can be passed around
const log = [];
const p = new Proxy({}, {
set(t, k, v) { log.push('set ' + k); t[k] = v; return true; },
defineProperty(t, k, d) { log.push('define ' + k); return Reflect.defineProperty(t, k, d); },
});
p.a = 1;
Object.defineProperty(p, 'b', { value: 2, writable: true, enumerable: true, configurable: true });
console.log(log.join(', '));p.a = 1 fires set, and the trap writes with t[k] = v straight onto the target, so nothing else is intercepted. Object.defineProperty never goes through set: it has its own trap. If the set trap had used Reflect.set(t, k, v, receiver) with the proxy as receiver, the define trap would have fired too, because the ordinary set algorithm ends in [[DefineOwnProperty]] on the receiver.
Validation: the trap you will actually ship
function validated(obj, rules) {
return new Proxy(obj, {
set(target, key, value, receiver) {
const rule = rules[key];
if (!rule) throw new TypeError(`Unknown property "${String(key)}"`);
if (!rule(value)) throw new TypeError(`Invalid value for "${String(key)}": ${String(value)}`);
return Reflect.set(target, key, value, receiver);
},
});
}
const user = validated({}, {
name: (v) => typeof v === 'string' && v.length > 0,
age: (v) => Number.isInteger(v) && v >= 0 && v < 150,
});
user.name = 'Ada';
console.log(user.name); // -> Ada
try { user.age = -1; } catch (e) { console.log(e.message); } // -> Invalid value for "age": -1
try { user.nmae = 'x'; } catch (e) { console.log(e.message); } // -> Unknown property "nmae"Fail at the write, not three functions later when the value is used.
Hiding, counting, faking
const raw = { name: 'Ada', _token: 'secret', _id: 7 };
const hidden = new Proxy(raw, {
has(t, k) {
return typeof k === 'string' && k.startsWith('_') ? false : Reflect.has(t, k);
},
get(t, k, r) {
return typeof k === 'string' && k.startsWith('_') ? undefined : Reflect.get(t, k, r);
},
deleteProperty(t, k) {
if (typeof k === 'string' && k.startsWith('_')) throw new TypeError('cannot delete private keys');
return Reflect.deleteProperty(t, k);
},
ownKeys(t) {
return Reflect.ownKeys(t).filter((k) => !(typeof k === 'string' && k.startsWith('_')));
},
});
console.log(Object.keys(hidden)); // -> [ 'name' ]
console.log('_token' in hidden); // -> false
console.log(hidden._token); // -> undefined
console.log(JSON.stringify(hidden)); // -> {"name":"Ada"}
try { delete hidden._id; } catch (e) { console.log(e.message); } // -> cannot delete private keyshas, deleteProperty and ownKeys working together to hide private keys.
function add(a, b) {
return a + b;
}
const counted = new Proxy(add, {
apply(target, thisArg, args) {
counted.calls += 1;
return Reflect.apply(target, thisArg, args);
},
});
counted.calls = 0;
console.log(counted(1, 2), counted(3, 4), counted.calls); // -> 3 7 2
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
const Logged = new Proxy(Point, {
construct(Target, args, newTarget) {
console.log('new Point with', args.join(', '));
return Reflect.construct(Target, args, newTarget);
},
});
const p = new Logged(1, 2);
console.log(p instanceof Point, p.x, p.y); // -> true 1 2apply and construct wrap functions, not just plain objects.
function withNegativeIndex(arr) {
return new Proxy(arr, {
get(target, key, receiver) {
const n = typeof key === 'string' ? Number(key) : NaN;
if (Number.isInteger(n) && n < 0) return target[target.length + n];
return Reflect.get(target, key, receiver);
},
});
}
const list = withNegativeIndex(['a', 'b', 'c']);
console.log(list[-1], list[-2], list[0]); // -> c b a
console.log(list.length, list.at(-1)); // -> 3 c everything else still works
console.log([...list].join('')); // -> abc
console.log(Array.isArray(list)); // -> true, exotic behaviour is preservedNegative indices, the classic Proxy demo, and one of the few places it earns its cost.
Revocable proxies
const secretData = { balance: 100 };
const { proxy, revoke } = Proxy.revocable(secretData, {});
console.log(proxy.balance); // -> 100
revoke();
try {
console.log(proxy.balance);
} catch (e) {
console.log(e.constructor.name, 'after revoke'); // -> TypeError after revoke
}
console.log(secretData.balance); // -> 100, the target is untouchedHand out access, then take it back. Every trap throws after revoke.
Invariants: the rules a proxy cannot break
A proxy may lie, but not so much that it breaks the object model. The engine checks a set of invariants after your trap returns and throws a TypeError if you violated one. The invariants exist so that code which has already frozen an object can trust it.
getmust return the exact value of a non-writable, non-configurable data property.hascannot reportfalsefor a non-configurable own property.deletePropertycannot reporttruefor a non-configurable property.ownKeysmust include every non-configurable own key, and must not repeat a key.isExtensiblemust agree withReflect.isExtensible(target).getPrototypeOfmust return the real prototype if the target is non-extensible.
const frozen = Object.freeze({ answer: 42 });
const liar = new Proxy(frozen, {
get() {
return 'nope';
},
});
try {
console.log(liar.answer);
} catch (e) {
console.log(e.constructor.name + ':', e.message.slice(0, 60));
}
// -> TypeError: 'get' on proxy: property 'answer' is a read-only and ...A lie the engine refuses to tell for you.
Where proxies stop being transparent
| Problem | What happens | Workaround |
|---|---|---|
private #fields | method called through the proxy throws TypeError | bind methods to the target, or proxy a plain object |
Map, Set, Date, Promise | internal slots need the real this, so methods throw | in get, return value.bind(target) for functions |
=== identity | proxy and target are different objects | never mix wrapped and unwrapped references |
| performance | every access is a function call, no inline caching | do not proxy hot paths or per-frame data |
typeof | unaffected, reports the target kind | this one is a feature |
| nested objects | a get returns the raw child, unproxied | wrap children lazily on read |
const realMap = new Map([['a', 1]]);
const broken = new Proxy(realMap, {});
try {
broken.get('a');
} catch (e) {
console.log('broken:', e.constructor.name); // -> broken: TypeError
}
const fixed = new Proxy(realMap, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
console.log('fixed:', fixed.get('a'), fixed.size); // -> fixed: 1 1The Map problem and its one-line fix. This bites everyone exactly once.
Interactive visualiser: proxy. Enable JavaScript to use it.
Try it yourself
Log every trap
const log = [];
const handler = {};
for (const trap of [
'get', 'set', 'has', 'deleteProperty', 'ownKeys',
'getOwnPropertyDescriptor', 'defineProperty', 'getPrototypeOf',
]) {
handler[trap] = (...args) => {
const key = args[1];
log.push(trap + (typeof key === 'string' ? ' ' + key : ''));
return Reflect[trap](...args);
};
}
const spied = new Proxy({ a: 1, b: 2 }, handler);
spied.a;
spied.c = 3;
'a' in spied;
delete spied.b;
Object.keys(spied);
JSON.stringify(spied);
console.log(log);
Add for (const k in spied) {} and count the traps it fires. Then try structuredClone(spied) and explain the result.
A tiny reactive object
function observable(target, onChange) {
return new Proxy(target, {
set(obj, key, value, receiver) {
const had = Reflect.has(obj, key);
const before = obj[key];
const ok = Reflect.set(obj, key, value, receiver);
if (ok && (!had || before !== value)) onChange({ type: had ? 'update' : 'add', key, value, before });
return ok;
},
deleteProperty(obj, key) {
const ok = Reflect.deleteProperty(obj, key);
if (ok) onChange({ type: 'delete', key });
return ok;
},
});
}
const state = observable({ count: 0 }, (change) => console.log('change:', change));
state.count = 1; // -> update
state.count = 1; // no log, the value did not change
state.name = 'cart'; // -> add
delete state.name; // -> delete
Make it track nested objects by wrapping any object value on the way out of get. Then add a batch(fn) that fires listeners once at the end.
Exercises
Negative array indices
Write negativeIndex(arr) returning a proxy where a negative index counts back from the end, for reading, writing and the in operator. Everything else about the array must keep working: length, methods, spread and Array.isArray. An out-of-range negative index reads as undefined.
A strict record
Write strictRecord(schema, initial = {}). schema maps key names to typeof strings, for example { name: 'string', age: 'number' }. Return a proxy that: throws a TypeError when you assign an unknown key or a value of the wrong type, throws a TypeError on any delete, reports true from in only for schema keys, and always reports every schema key from Object.keys in schema order even if it was never assigned. Initial values must go through the same validation.
Check yourself
- What does this log?
false true 1— Thehastrap answersin, and it saysfalsefor'a'andtruefor everything else, so'a' in pisfalseand'b' in pistrue. Readingp.agoes through[[Get]], which has no trap here, so it falls through to the target and returns1. A proxy can makeinand.disagree, which is exactly why you should not.- Why pass
receiveras the third argument toReflect.get? - So that a getter on the target runs with
thisbound to the proxy rather than the target — The receiver is whatthiswill be inside any accessor that the lookup finds. Passing the proxy means a getter readingthis.othergoes back through your traps, which is what you almost always want. It is optional (it defaults to the target) and it changes semantics, not speed. Recursion prevention is not a thing: forwarding toReflecton the target is what avoids the loop. - Which operations does
Object.keys(proxy)trigger? - the
ownKeystrap, thengetOwnPropertyDescriptoronce per returned key —Object.keysneeds to filter for enumerable string keys, so it collects the key list fromownKeysand then asks for each key's descriptor. That is why a proxy which returns keys fromownKeysbut no descriptor for them produces an emptyObject.keysresult, a genuinely confusing bug the first time you meet it. - You wrap a
Mapinnew Proxy(map, {})and callproxy.get('a'). What happens? - it throws a
TypeError, becauseMap.prototype.getneeds the real map asthis— Built-ins likeMap,Set,DateandPromisekeep their data in internal slots, and their methods check thatthishas those slots. Called through a proxy,thisis the proxy, which has none, so you get "Method Map.prototype.get called on incompatible receiver". The fix is agettrap that returnsvalue.bind(target)for function values.
Common mistakes
- Writing
target[key] = valuein asettrap and returningtruewithout checking that it worked. - Dropping the
receiverargument, which silently breaks getters that read other properties. - Expecting
Object.definePropertyto fire thesettrap. It firesdefineProperty. - Returning keys from
ownKeyswith no matchinggetOwnPropertyDescriptor, soObject.keyscomes back empty. - Proxying a
Map,Set,Dateor a class with#privatefields and being surprised byTypeError. - Comparing a proxy to its target with
===and expectingtrue. - Putting a proxy on a hot path. Every property read becomes a function call with no inline cache.
- Assuming a
gettrap deep-wraps nested objects. It returns the raw child unless you wrap it yourself.
Takeaways
- A proxy intercepts operations, not properties. There are thirteen traps and each maps to a specific internal method.
Reflect.xis the default behaviour of trapx, with the correct return value and receiver support.- Always forward the
receiveringetandset, or getters will see the wrongthis. - One statement can fire several traps:
for...inandObject.keysgo throughownKeysplus descriptors. - The engine enforces invariants, so a proxy cannot lie about frozen or non-configurable properties.
Proxy.revocablegives you access you can withdraw, which is the right tool at a trust boundary.- Proxies are not transparent for private fields, built-ins with internal slots, identity, or performance.