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);  // -> 5

The smallest useful proxy: a default value instead of undefined.

All thirteen traps

TrapFires onMust return
get(t, key, receiver)obj.x, obj[x], destructuringthe value
set(t, key, value, receiver)obj.x = 1, obj[x] = 1true, or false to throw in strict mode
has(t, key)key in obj, withboolean
deleteProperty(t, key)delete obj.xboolean
apply(t, thisArg, args)fn(...), fn.call, fn.applyanything
construct(t, args, newTarget)new Fn(...)an object
getPrototypeOf(t)Object.getPrototypeOf, instanceof, __proto__object or null
setPrototypeOf(t, proto)Object.setPrototypeOfboolean
isExtensible(t)Object.isExtensibleboolean matching the target
preventExtensions(t)Object.preventExtensions, freeze, sealboolean
getOwnPropertyDescriptor(t, key)Object.getOwnPropertyDescriptor, Object.keysdescriptor or undefined
defineProperty(t, key, desc)Object.defineProperty, Object.assign targetsboolean
ownKeys(t)Object.keys, for...in, spread, JSON.stringifyarray 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.apply that cannot be shadowed by the object
`Reflect.construct(Fn, args, NewTarget)`
new with a swappable prototype source
`Reflect.has(t, k)`
in as 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 keys

has, 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 2

apply 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 preserved

Negative 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 untouched

Hand 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.

  • get must return the exact value of a non-writable, non-configurable data property.
  • has cannot report false for a non-configurable own property.
  • deleteProperty cannot report true for a non-configurable property.
  • ownKeys must include every non-configurable own key, and must not repeat a key.
  • isExtensible must agree with Reflect.isExtensible(target).
  • getPrototypeOf must 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

ProblemWhat happensWorkaround
private #fieldsmethod called through the proxy throws TypeErrorbind methods to the target, or proxy a plain object
Map, Set, Date, Promiseinternal slots need the real this, so methods throwin get, return value.bind(target) for functions
=== identityproxy and target are different objectsnever mix wrapped and unwrapped references
performanceevery access is a function call, no inline cachingdo not proxy hot paths or per-frame data
typeofunaffected, reports the target kindthis one is a feature
nested objectsa get returns the raw child, unproxiedwrap 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 1

The 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 — The has trap answers in, and it says false for 'a' and true for everything else, so 'a' in p is false and 'b' in p is true. Reading p.a goes through [[Get]], which has no trap here, so it falls through to the target and returns 1. A proxy can make in and . disagree, which is exactly why you should not.
Why pass receiver as the third argument to Reflect.get?
So that a getter on the target runs with this bound to the proxy rather than the target — The receiver is what this will be inside any accessor that the lookup finds. Passing the proxy means a getter reading this.other goes 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 to Reflect on the target is what avoids the loop.
Which operations does Object.keys(proxy) trigger?
the ownKeys trap, then getOwnPropertyDescriptor once per returned key — Object.keys needs to filter for enumerable string keys, so it collects the key list from ownKeys and then asks for each key's descriptor. That is why a proxy which returns keys from ownKeys but no descriptor for them produces an empty Object.keys result, a genuinely confusing bug the first time you meet it.
You wrap a Map in new Proxy(map, {}) and call proxy.get('a'). What happens?
it throws a TypeError, because Map.prototype.get needs the real map as this — Built-ins like Map, Set, Date and Promise keep their data in internal slots, and their methods check that this has those slots. Called through a proxy, this is the proxy, which has none, so you get "Method Map.prototype.get called on incompatible receiver". The fix is a get trap that returns value.bind(target) for function values.

Common mistakes

  • Writing target[key] = value in a set trap and returning true without checking that it worked.
  • Dropping the receiver argument, which silently breaks getters that read other properties.
  • Expecting Object.defineProperty to fire the set trap. It fires defineProperty.
  • Returning keys from ownKeys with no matching getOwnPropertyDescriptor, so Object.keys comes back empty.
  • Proxying a Map, Set, Date or a class with #private fields and being surprised by TypeError.
  • Comparing a proxy to its target with === and expecting true.
  • Putting a proxy on a hot path. Every property read becomes a function call with no inline cache.
  • Assuming a get trap 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.x is the default behaviour of trap x, with the correct return value and receiver support.
  • Always forward the receiver in get and set, or getters will see the wrong this.
  • One statement can fire several traps: for...in and Object.keys go through ownKeys plus descriptors.
  • The engine enforces invariants, so a proxy cannot lie about frozen or non-configurable properties.
  • Proxy.revocable gives 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.