Proxy and Reflect
This chapter assumes you already understand objects, property descriptors (from the Prototype Chain chapter), and this binding. Proxy and Reflect are the two primitives that make metaprogramming — programs that inspect and modify other programs' behavior — possible in JavaScript. They rarely appear in application code, but they underpin frameworks you use daily: Vue's reactivity, MobX, test mocks, and ORM lazy-loading all rely on them.
A Proxy wraps a target object and intercepts fundamental operations — property read, write, in, delete, function calls, iteration — via optional traps. Reflect mirrors the same operations as plain functions, providing the canonical default implementation.
const target = { a: 1 };
const proxy = new Proxy(target, {
get(target, prop, receiver) {
console.log(`read ${String(prop)}`);
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`write ${String(prop)} = ${value}`);
return Reflect.set(target, prop, value, receiver);
}
});
proxy.a; // read a
proxy.b = 2; // write b = 2
console.log(target.b); // 2 — the target actually changedThe receiver argument matters when getters and setters are involved: passing it (rather than target) preserves this semantics for accessors defined on the prototype chain. This is the single most common correctness detail people get wrong, and we return to it under Failure modes.
Why Reflect exists
Before Reflect, overriding one operation meant reimplementing the others by hand. Reflect gives you the default behavior of every trap as a first-class function:
| Trap | Reflect equivalent |
|---|---|
| `get` | `Reflect.get(target, prop, receiver)` |
| `set` | `Reflect.set(target, prop, value, receiver)` |
| `has` | `Reflect.has(target, prop)` |
| `deleteProperty` | `Reflect.deleteProperty(target, prop)` |
| `apply` | `Reflect.apply(fn, thisArg, args)` |
| `ownKeys` | `Reflect.ownKeys(target)` |
The rule of thumb: inside a trap, delegate to the corresponding Reflect method with the arguments you received. Only deviate deliberately.
Trade offs
Proxies trade transparency for power, and the costs are real.
- Performance. Every intercepted operation goes through an internal trap dispatch. A
gettrap on a hot path can be 10–100× slower than a plain property read, depending on engine and trap complexity. Browsers and V8 optimize monomorphic direct reads aggressively; proxies defeat that. Avoid proxies on hot data structures (rendering loops, tight numeric computation). - Invariants cost correctness freedom. Certain lies are forbidden: a proxy whose target has a non-configurable, non-writable data property cannot have its
gettrap return a different value;ownKeysmust include all non-configurable own keys of the target; the result ofgetOwnPropertyDescriptormust be consistent with the target. The engine throws aTypeErrorwhen a trap violates these. This protects ecosystem code from impossible objects, but it constrains what virtual objects you can build — pick a target shape that matches your fiction. - Identity leaks. A proxy is not its target.
===,Map/Setkeys,WeakMapkeys, andArray.isArray(which special-cases proxies of arrays correctly, but only by spec) all treat them distinctly. If code compares objects by identity, wrapping them changes behavior. - Debuggability. Stack traces through traps,
console.logof proxies, and devtools inspection can be confusing. Name your handler functions.
When to reach for a proxy
| Use case | Fit |
|---|---|
| Reactive frameworks (deep observation) | Excellent — this is the design center |
| Logging/metrics on an API surface | Good |
| Validation on write | Good |
| Virtualizing huge/fetched-on-demand objects | Good, with care |
| Performance-critical inner loops | Poor — use plain objects |
| Deeply cloning-on-access object graphs | Poor — identity semantics break |
Failure modes
- Dropping
receiver. If a getter on the prototype chain reads another property viathis, forwardinggetwithtargetinstead ofreceiverbreaks the chain:thisbecomes the target, not the proxy, and subsequent reads bypass your traps. Always forwardreceiver. - Non-invariant traps returning inconsistent data. Returning
undefinedfromgetOwnPropertyDescriptorfor a property you also report inownKeysthrows. Similarly,setmust returntrueon success in strict mode — returningfalseorundefined(falsy) surfaces as aTypeErrorat the assignment site, far from the trap, which makes it painful to diagnose. - Forgetting that internal slots are invisible to proxies. A
Date,Map,Set,Promise, orRegExpstores its state in internal slots, not properties. A proxy of aDatecannot work —proxy.getTime()throws becausethisis the proxy, which lacks the slot.
const d = new Proxy(new Date(), {});
d.getTime(); // TypeError: this is not a Date objectWorkarounds: bind methods up front (new Proxy(target, { get: (t, p) => Reflect.get(t, p).bind?.(t) ?? Reflect.get(t, p) })) or store state in ordinary properties instead.
- Revoked proxies are forever.
Proxy.revocablegives you arevoke()function; after it runs, every operation on the proxy throwsTypeError. Useful for capability-based access control, fatal if revoked while an async callback still holds the proxy. - Recursive wrapping. Naively wrapping nested objects on
getcreates a new proxy per read, sodeep.a === deep.aisfalseandSetmembership fails. Cache wrappers per target (typically in aWeakMap).
const wrappers = new WeakMap();
function deepProxy(target, handler) {
if (wrappers.has(target)) return wrappers.get(target);
const proxy = new Proxy(target, handler);
wrappers.set(target, proxy);
return proxy;
}Internals
At the spec level, every ordinary-object operation is defined as an abstract operation (e.g., [[Get]], [[Set]], [[OwnPropertyKeys]]) that takes a receiver. Proxy implements the same internal methods, but each one checks whether the handler defines the corresponding trap; if not, or if the trap returns a non-object in places where one is required, it falls back to the default behavior applied to the target.
The receiver parameter exists precisely so Reflect.get(target, key, receiver) can be the exact fallback the spec performs. That symmetry is why "delegate with the same arguments" is a complete, correct default.
caption: Proxy get flow with trap dispatch and fallback
alt: A flowchart showing a property access entering the proxy's internal Get method, branching to either the handler's get trap or a fallback to the target, with an invariant check that can raise a TypeError.
Engines implement trap dispatch as a fast path: if the handler is a plain object with no traps (a "revocable no-op" or empty handler), V8 can inline the fallback. Adding traps deoptimizes the target's hidden class transitions around the proxy boundary, which is why wrapping inside a library's public API — not its internals — is the standard practice.
A final design criterion: proxies intercept fundamental operations, not syntactic forms. proxy.x, proxy['x'], and destructuring const { x } = proxy all hit get; there is no trap for "dot access specifically". Choose traps by operation, never by syntax.
Exercise
Why must a get trap forward the receiver argument to Reflect.get instead of passing target, and what breaks if it doesn't?
Show answer
If the target (or its prototype chain) has accessor properties that read other properties via this, this must be the original proxy so those reads re-enter the traps. Passing target binds this to the raw target, so subsequent property reads bypass the proxy entirely and go unobserved (or return stale values in a reactivity system).
Exercise
You wrap a Map in a proxy and map.get('k') throws. Why, and what are two fixes?
Show answer
Map.prototype.get reads internal slots on this, and this is the proxy, which has no slots. Fix 1: return bound methods from the get trap (Reflect.get(...).bind(target)). Fix 2: don't proxy the Map; expose it behind an API object with ordinary-property state, or trap at a wrapper level above the Map.
Spotted a mistake, or code that does not work?