gap·map

Proxy & Reflect

[ middle depth ]

A Proxy wraps the target without changing it

const user = { name: "Ada" };
const p = new Proxy(user, {
  get(target, prop) { return prop in target ? target[prop] : "n/a"; },
});
p.age;     // "n/a" — trap fired
user.age;  // undefined — the original is untouched
p === user // false

Each of these results kills a common misconception:

  • The target keeps existing, unchanged. A proxy never instruments the object it wraps: any code holding a direct reference to user bypasses every trap. Interception exists only on the path through the proxy.
  • The proxy is a different object. ===, Set.has, and WeakMap keys all see two objects.
  • An undefined trap falls through to the target. new Proxy(obj, {}) is a transparent forwarder; you only intercept what you name.

Traps intercept the object's internal methods, the spec-level operations every object supports. Property access is [[Get]], assignment is [[Set]], and so on. A trap replaces one internal method.

Which trap fires for which operation

TrapFires onReal use
getp.x, p[x]defaults, negative array indexes, lazy values
setp.x = vvalidation: reject bad writes at the boundary
hasx in phide _internal keys from in
deletePropertydelete p.xforbid/track deletion
ownKeysObject.keys, for..in, spreadfilter what enumeration sees
applyp(...)spies, timing, logging around a function
constructnew p(...)test doubles, injecting subclasses

There are also descriptor/prototype/extensibility traps (getOwnPropertyDescriptor, defineProperty, getPrototypeOf…). Rarer, same idea.

Two mechanical gotchas. Property keys arrive as strings or symbols, so array index -1 reaches your trap as the string "-1"; coerce before doing math. And set/deleteProperty traps must return a boolean, true for success. Returning nothing is falsy and throws a TypeError in strict mode.

Why traps should forward with Reflect

Reflect has one static method per trap, with the same signature. Reflect.get(target, prop, receiver) is the default [[Get]]; Reflect.set is the default [[Set]]. "Do the normal thing, plus my extra behavior" becomes one line:

const p = new Proxy(user, {
  get(target, prop, receiver) {
    log(prop);
    return Reflect.get(target, prop, receiver); // or Reflect.get(...arguments)
  },
});

Why not return target[prop]? Because of the third argument. Watch what happens with an inherited getter:

const user = { _name: "Guest", get name() { return this._name; } };
const proxy = new Proxy(user, {
  get(target, prop, receiver) { return target[prop]; }, // bug
});
const admin = { __proto__: proxy, _name: "Admin" };
admin.name; // "Guest" — wrong!

Reading admin.name misses on admin, walks up the prototype chain (the lookup mechanics are the prototypes topic), and hits the proxy's get trap. receiver is admin, the object the read started on, and the getter's this should be admin. target[prop] throws that information away and runs the getter with this = target. Reflect.get(target, prop, receiver) carries it through and returns "Admin". So forward with Reflect and pass receiver every time: it costs nothing when there are no getters and is the only correct behavior when there are.

Reflect earns its keep a second way. Reflect.set, Reflect.defineProperty, and Reflect.deleteProperty return booleans, exactly what your trap must return, while their Object.* counterparts throw on failure, the wrong shape for trap code. Reflect is not Object renamed; it is the trap-shaped API.

Revocable proxies and membranes

const { proxy, revoke } = Proxy.revocable(secrets, {});
plugin.init(proxy); // hand out a capability…
revoke();
proxy.token; // TypeError — …and take it back

After revoke(), every operation on the proxy throws, and the proxy drops its target reference, so the target can be GC'd. Scale the idea up and you get a membrane: wrap an object graph so that every object crossing the boundary, arguments in and return values out, gets wrapped too. One revoke then severs an untrusted party (a plugin, an iframe bridge, a sandboxed module) from the entire graph at once. You will rarely build one yourself; when interviewers ask what proxies are for beyond frameworks, the answer they grade against is revocable access to an object graph.

[ senior depth ]

Invariants: what a trap cannot lie about

Traps are not omnipotent. After a trap returns, the engine checks its answer against the target and throws a TypeError if the trap violated an invariant. The theme: you may only lie about what's configurable.

const obj = Object.freeze({ a: 10 });
const liar = new Proxy(obj, { get: () => 20 });
liar.a; // TypeError — not 20, not even 10 with a warning. A throw.

The concrete rules: get must report the real value for a non-writable, non-configurable own data property, and undefined for a non-configurable accessor with no getter. set/deleteProperty must not claim success on non-writable/non-configurable properties. has can't hide a non-configurable key; ownKeys can't omit one (nor invent keys on a non-extensible target). getPrototypeOf on a non-extensible target must report the true prototype. Consequence: a frozen object is fully honest through any proxy. Freeze is a hard floor that interception can't fake. The spec is protecting a guarantee here: code that checked a descriptor and saw configurable: false must be able to rely on it, so proxies may only decorate the negotiable parts.

Where proxies leak in practice

  • Identity. proxy !== target. Anything keyed by identity (=== checks, Set.has, WeakMap caches, React dependency comparisons) sees two objects. This is why reactive frameworks ship toRaw, and why mixing raw and wrapped references corrupts caches.
  • Private fields. #name isn't a property; it lives in a slot check tied to the actual object. Call a method through a proxy and the method runs with this = proxy, which has no slotTypeError. No trap fires; there's nothing to trap.
  • Built-ins' internal slots. Map, Set, Date, Promise methods read [[MapData]]-style slots directly off this, bypassing [[Get]]/[[Set]] entirely. new Proxy(new Map(), {}).set("k", 1) throws. Workaround: the get trap returns methods bound to the target (value.bind(target)), which is why Vue wraps collections with a separate, hand-instrumented handler set rather than the plain object traps.
  • JSON.stringify serializes the trapped view: it drives ownKeys + get, so keys your traps hide vanish from the output, and every property read runs trap code. Serializing reactive state fires the whole tracking machinery for nothing.
  • Performance. Every trapped operation is a JS function call replacing what was an inlined engine fast path. One read: irrelevant. A tight loop over a proxied array of 100k rows: measurable, sometimes dominant. Hot paths should run on raw data: unwrap first, compute, write results back through the proxy once.

The reactivity engine, end to end

The generic pattern behind Vue 3's reactive, MobX observables, and Immer's draft tracking is track on get, trigger on set:

const targetMap = new WeakMap(); // target → Map(key → Set(effect))
const proxyCache = new WeakMap(); // raw → proxy
let activeEffect = null;

function reactive(obj) {
  if (proxyCache.has(obj)) return proxyCache.get(obj);
  const proxy = new Proxy(obj, {
    get(t, k, r) {
      track(t, k); // subscribe activeEffect to (t, k)
      const v = Reflect.get(t, k, r);
      return typeof v === "object" && v !== null ? reactive(v) : v;
    },
    set(t, k, v, r) {
      const old = t[k];
      const ok = Reflect.set(t, k, v, r);
      if (ok && old !== v) trigger(t, k); // re-run subscribers
      return ok;
    },
    deleteProperty(t, k) {
      const had = Object.hasOwn(t, k);
      const ok = Reflect.deleteProperty(t, k);
      if (ok && had) trigger(t, k);
      return ok;
    },
  });
  proxyCache.set(obj, proxy);
  return proxy;
}

Every level of targetMap is motivated: WeakMap so dropped objects GC with their dependency data; per-key Maps so a write re-runs only effects that read that key; Sets to dedupe. effect(fn) sets activeEffect = fn, runs fn (whose reads call track), then restores it. Real engines keep a stack there, for nested effects. Four details separate this from a whiteboard sketch: lazy nested wrapping (objects are wrapped on read, not by walking the graph up front), the raw→proxy cache keeping identity stable across reads, the no-op guard (old !== v) preventing trigger storms, and deleteProperty triggering too. Real engines also track has (in) and ownKeys (iteration), and treat array index writes as touching length.

Why Object.defineProperty (Vue 2's substrate) lost: it converts properties, so it must know every key at init time. Added keys, delete, arr[3] = x, arr.length = 0: all invisible, hence Vue.$set and a decade of bug reports. Proxy traps the operations, so anything you do to the object flows through. What Proxy still can't fix: const { count } = state copies a primitive out, a disconnected value no trap will ever hear about again. Reactivity lives on the object wrapper; primitives need their own box (that's all a ref is).

When not to use a Proxy

Decline a Proxy when the behavior fits a getter/setter or a plain function (which covers most validation and computed-value cases), when the code has to stay debuggable by a team (traps make stepping and stack traces indirect, and console.log prints Proxy(...) wrappers), or when the path is hot (see above). Proxies earn their cost in framework internals (reactivity, ORMs), boundary hardening (membranes, revocable capabilities, API deprecation shims), and test tooling (spies via apply, auto-mocks via get).

Interviewers grade this topic on a few specific moves: defining a proxy as a wrapper over the internal methods, forwarding with Reflect + receiver by reflex, volunteering the invariants ("you can't lie about non-configurables, a frozen target is fully honest") and the leaks (identity, #fields, internal slots, perf) before being asked, and sketching track/trigger with the WeakMap→Map→Set structure. Knowing when you'd refuse the tool counts for as much as knowing how to use it.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more JavaScript