Proxy and Reflect Work Best When You Respect the Invariants
A practical guide to interception, receiver semantics, and the rules a proxy cannot pretend away.
A Proxy can intercept fundamental object operations: reading a property, assigning a value, listing keys, defining a property, or calling a function. That power is useful for validation and observation, but it does not allow a proxy to invent an entirely different object model.
The target still has non-configurable properties, extensibility state, and prototype rules. Proxy traps must remain consistent with those facts. Violations throw a TypeError, often at the operation that triggered the trap rather than where the proxy was created.
Reflect provides the default operation
Most transparent proxies should delegate to the corresponding Reflect method after adding their behaviour.
const observed = new Proxy(model, {
get(target, property, receiver) {
console.log('read', property);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
console.log('write', property, value);
return Reflect.set(target, property, value, receiver);
},
});
Reflect methods match the shape of proxy traps and return useful status values. Reflect.defineProperty, for example, returns false instead of throwing solely because definition failed.
When a getter itself throws, Reflect.get converts that exception into undefined and lets the proxy continue. Direct property access propagates the exception, so reflective forwarding is useful when observation code must not disturb the caller.
Receivers matter along prototype chains
Consider a getter inherited through a proxy:
const target = {
get label() {
return this.prefix + ':item';
},
};
const proxy = new Proxy(target, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver);
},
});
const child = Object.create(proxy);
child.prefix = 'draft';
Reading child.label should use child as the getter’s this. Forwarding the receiver preserves that behaviour. Similar care is needed in set traps because a setter or inherited writable property may ultimately affect the receiver rather than the original target.
Private class fields are intentionally different. Their access checks use an internal brand, not normal property lookup. A method called with a proxy as this may fail to access a private field even when the proxy targets the branded instance. Transparent proxying of classes with private state often requires binding methods back to the target, which changes other identity semantics.
Invariants constrain believable results
Suppose the target has a non-configurable, non-writable data property. A get trap cannot report a different value for it. A has trap cannot hide a non-configurable own property. If the target is non-extensible, ownKeys must report exactly its own keys.
When a target remains extensible, however, an ownKeys trap may omit non-configurable properties as long as it reports every configurable key. Extensibility means the reported view is allowed to behave like a partial virtual object.
These rules protect code that relies on descriptors and extensibility. Without them, Object.freeze(target) could say one thing while a proxy presented an impossible alternative.
Trap return values are part of the protocol
Mutation traps such as set, deleteProperty, and defineProperty report success with a boolean. Returning false from a set trap makes assignment fail. In strict-mode code, that failure becomes a TypeError; in sloppy mode it is generally silent.
This makes Reflect useful for validation proxies:
const settings = new Proxy({}, {
set(target, key, value, receiver) {
if (key === 'retries' && !Number.isInteger(value)) return false;
return Reflect.set(target, key, value, receiver);
},
});
Proxies are easiest to maintain when each trap adds one policy and then delegates the language mechanics. Treating Reflect as the baseline also makes the exceptional cases—the places where the proxy intentionally lies—visible in code review.