Skip to main content
JavaScript · lesson 29 of 32
JavaScript advanced Lesson 29 of 32

Proxy and Reflect in JavaScript

Learn how a JavaScript Proxy intercepts property access with get and set traps, why Reflect.set needs a receiver argument to forward correctly, and what Proxy.revocable throws once revoked.

By SkillByExample · Published

javascript proxy reflect metaprogramming

A Proxy wraps a target object and lets you intercept the fundamental operations performed on it — reading a property, writing one, checking in, deleting a key — with your own functions, called traps. Reflect is the companion object: its methods perform those same fundamental operations directly, which is what a trap calls when it wants to fall through to default behavior instead of overriding it. Every example below runs with node on Node v24.18.1.

The get Trap: Supplying a Default for a Missing Property

Reading a key a plain object doesn’t have returns undefined, with no record of which key was missing. A get trap can intercept that and return something more useful instead — while leaving in and Object.keys reporting the target’s real, unchanged shape.

const withDefaults = (target, fallback) =>
  new Proxy(target, {
    get(obj, prop, receiver) {
      if (prop in obj) return Reflect.get(obj, prop, receiver);
      return fallback[prop] ?? `<no ${prop}>`;
    },
  });

const user = withDefaults({ name: "Ada" }, { email: "n/a" });

console.log(user.name, user.email);
console.log(user.age);

console.log("email" in user, Object.keys(user));
$ node get-defaults.js
Ada n/a
<no age>
false [ 'name' ]

user.email reads "n/a" from the fallback object, because "email" isn’t a key of the real target { name: "Ada" }. user.age isn’t in fallback either, so the trap’s own ?? `<no ${prop}>` fallback kicks in and invents "<no age>" on the spot. Neither value was ever written anywhere: "email" in user is still false, and Object.keys(user) still lists only 'name' — the trap changes what a read returns, not what the object actually has. That gap is worth remembering: code that checks in or iterates Object.keys before reading a property will miss every default the get trap supplies.

The set Trap: Rejecting an Invalid Write Before It Happens

A set trap runs before a property is actually written, so it can validate the incoming value and refuse the assignment by throwing — and because the trap controls whether the write happens at all, a rejected assignment never touches the target.

function validated(target) {
  return new Proxy(target, {
    set(obj, prop, value, receiver) {
      if (prop === "age" && !Number.isInteger(value)) {
        throw new TypeError(`age must be an integer, got ${JSON.stringify(value)}`);
      }
      return Reflect.set(obj, prop, value, receiver);
    },
  });
}

const target = { name: "Ada", age: 36 };
const account = validated(target);

account.age = 37;
console.log(account.age, target.age);

try {
  account.age = "old enough";
} catch (err) {
  console.log(err.constructor.name + ":", err.message);
}

console.log(account.age, target.age);
$ node set-validate.js
37 37
TypeError: age must be an integer, got "old enough"
37 37

account.age = 37 passes validation, and both account.age and the underlying target.age read 37 — the write went through Reflect.set, which really did set it. account.age = "old enough" throws before Reflect.set ever runs, so the final line still reads 37 37: the rejected assignment left target exactly as it was, not half-written. A set trap that validated but forgot to return (or returned false) wouldn’t fail silently, exactly — in this script’s sloppy-mode .js file, the assignment account.age = 37 would still write through Reflect.set as normal; only the trap’s own reported success/failure (the boolean the engine reads to decide whether the assignment “worked”) would come back wrong. In strict-mode code — a class body, an ES module, or a "use strict" file — the engine checks that boolean itself and throws TypeError: 'set' on proxy: trap returned falsish for property 'age' instead of letting the mismatch pass unnoticed.

Why Reflect’s receiver Argument Matters

Reflect.get(obj, prop) and Reflect.set(obj, prop, value) look like they just do what obj[prop] and obj[prop] = value already do — and in the two examples above, they do, because the proxy itself is the object the access happened through. The two diverge once a proxy is used as another object’s prototype, where the access arrives through a different object (the receiver) than the one the trap sees as obj (the target).

const base = { x: 0 };

const wrongTrap = new Proxy(base, {
  set(obj, prop, value) {
    obj[prop] = value; // ignores the receiver -- always writes to the proxy's own target
    return true;
  },
});

const correctTrap = new Proxy(base, {
  set(obj, prop, value, receiver) {
    return Reflect.set(obj, prop, value, receiver); // forwards the receiver
  },
});

const childA = Object.create(wrongTrap);
const childB = Object.create(wrongTrap);
childA.x = 1;
childB.x = 2;
console.log("wrong:", childA.x, childB.x, base.x);

base.x = 0;
const childC = Object.create(correctTrap);
const childD = Object.create(correctTrap);
childC.x = 1;
childD.x = 2;
console.log("correct:", childC.x, childD.x, base.x);
console.log(Object.hasOwn(childC, "x"), Object.hasOwn(childD, "x"));
$ node receiver.js
wrong: 2 2 2
correct: 1 2 0
true true

childA and childB both inherit from wrongTrap, so childA.x = 1 triggers the set trap with obj bound to base — but the trap writes obj[prop] = value directly, ignoring which object the assignment was actually made through. Every write lands on the one shared base, so childA.x and childB.x both end up reading 2, the last value written, and base.x is 2 too: two supposedly independent objects collapsed into one shared mutable value. correctTrap fixes this by forwarding the fourth argument, receiver, to Reflect.set — the engine passes receiver as whichever object the assignment actually started from (childC or childD), so Reflect.set(obj, prop, value, receiver) creates a new own property on that receiver instead of writing through to base. childC.x and childD.x now hold independent values, base.x is untouched at 0, and Object.hasOwn confirms both children really got their own x property rather than one shared through the prototype chain.

Proxy.revocable: Disabling a Proxy on Command

new Proxy(target, handler) creates a proxy with no way to turn it off later. Proxy.revocable(target, handler) returns { proxy, revoke } instead — the same kind of proxy, plus a separate function that permanently disables it.

const target = { secret: "api-key-123" };
const { proxy, revoke } = Proxy.revocable(target, {});

console.log(proxy.secret);

revoke();

try {
  console.log(proxy.secret);
} catch (err) {
  console.log(err.constructor.name + ":", err.message);
}

try {
  proxy.secret = "new-value";
} catch (err) {
  console.log(err.constructor.name + ":", err.message);
}

console.log(target.secret);
$ node revocable.js
api-key-123
TypeError: Cannot perform 'get' on a proxy that has been revoked
TypeError: Cannot perform 'set' on a proxy that has been revoked
api-key-123

Before revoke() runs, proxy.secret behaves exactly like a plain, unhandled proxy — it reads straight through to target.secret, because the handler object passed in ({}) defines no traps at all. Once revoke() has run, every operation on proxy throws a TypeError, naming the specific trap that would have run ('get', then 'set') and stating plainly that the proxy has been revoked — Node’s exact wording, which is implementation detail rather than spec text, but consistent across both operations here. target.secret still reads "api-key-123" at the end: revoking disables the wrapper, not the object it was wrapping. A caller holding only proxy loses all access the moment someone else calls revoke(), which is the point — handing out a revocable proxy instead of the real object means access can be cut off later without the caller’s cooperation.

Practice

1. Add a has trap to withDefaults so "email" in user reports true for a key covered by the fallback, without changing what Object.keys reports.
const withDefaults = (target, fallback) =>
  new Proxy(target, {
    get(obj, prop, receiver) {
      if (prop in obj) return Reflect.get(obj, prop, receiver);
      return fallback[prop] ?? `<no ${prop}>`;
    },
    has(obj, prop) {
      return prop in obj || prop in fallback;
    },
  });

const user = withDefaults({ name: "Ada" }, { email: "n/a" });
console.log("email" in user, "age" in user, "nope" in user);
console.log(Object.keys(user));
$ node p1.js
true false false
[ 'name' ]

has is its own trap, separate from get — intercepting in requires defining has explicitly, and leaving get alone doesn’t make in consult it. "email" in user is now true because has checks fallback too, "age" and "nope" are still false because neither object has them, and Object.keys is unaffected because no ownKeys trap was added.

2. Change the validating set trap to reject a negative balance, and confirm a rejected write leaves the target unchanged.
function validated(target) {
  return new Proxy(target, {
    set(obj, prop, value, receiver) {
      if (prop === "balance" && (typeof value !== "number" || value < 0)) {
        throw new RangeError(`balance must be a non-negative number, got ${JSON.stringify(value)}`);
      }
      return Reflect.set(obj, prop, value, receiver);
    },
  });
}

const target = { balance: 100 };
const account = validated(target);
account.balance = 50;
console.log(account.balance, target.balance);

try {
  account.balance = -10;
} catch (err) {
  console.log(err.constructor.name + ":", err.message);
}

console.log(account.balance, target.balance);
$ node p2.js
50 50
RangeError: balance must be a non-negative number, got -10
50 50

The shape is identical to the age example — check before forwarding, throw instead of calling Reflect.set when the value is invalid. account.balance = -10 throws, and the last line proves the target was never touched: it still reads 50, the value from the one write that passed validation.

3. Write a read-only proxy whose set trap always throws, for any property.
function readOnly(target) {
  return new Proxy(target, {
    set(obj, prop) {
      throw new TypeError(`Cannot set property "${String(prop)}" on a read-only proxy`);
    },
  });
}

const frozenLike = readOnly({ pi: 3.14159 });
console.log(frozenLike.pi);

try {
  frozenLike.pi = 3;
} catch (err) {
  console.log(err.constructor.name + ":", err.message);
}

console.log(frozenLike.pi);
$ node p3.js
3.14159
TypeError: Cannot set property "pi" on a read-only proxy
3.14159

Unlike the validating traps above, this one takes no value-dependent branch at all — every assignment, to every property, throws before it reaches Reflect.set. frozenLike.pi reads normally throughout, because no get trap was defined; only writes are blocked. Object.freeze achieves something similar for a plain object, but a set trap can log the attempt, name the offending property in its own message, or allow some properties and not others — a freeze is all-or-nothing.

4. Build a one-time-use token: a proxy that reads normally for maxReads accesses, then revokes itself.
function limitedUse(target, maxReads) {
  let reads = 0;
  const { proxy, revoke } = Proxy.revocable(target, {
    get(obj, prop, receiver) {
      reads++;
      const value = Reflect.get(obj, prop, receiver);
      if (reads >= maxReads) revoke();
      return value;
    },
  });
  return proxy;
}

const token = limitedUse({ value: "one-time-secret" }, 2);
console.log(token.value);
console.log(token.value);

try {
  console.log(token.value);
} catch (err) {
  console.log(err.constructor.name + ":", err.message);
}
$ node p4.js
one-time-secret
one-time-secret
TypeError: Cannot perform 'get' on a proxy that has been revoked

The trap calls Reflect.get and captures its result before checking whether this was the last allowed read — so the read that crosses the limit still succeeds and returns the value, and revoke() only takes effect starting with the next access. That’s why the second token.value still prints "one-time-secret"; only the third attempt hits the now-revoked proxy and throws.

Frequently Asked Questions

What is the difference between a Proxy trap and a plain getter or setter?
A getter or setter is declared once, on one named property, as part of an object or class definition. A Proxy's get and set traps intercept every property access on the whole object — known keys and unknown ones alike — from outside, without the target object's own code needing to know a Proxy is involved. Use a getter for one property you control; use a Proxy when you need to intercept access generically, including properties that don't exist yet.
Why call Reflect.get or Reflect.set inside a trap instead of just reading or writing the target directly?
target[prop] and target[prop] = value look equivalent to Reflect.get(target, prop) and Reflect.set(target, prop, value) in the simple case, but Reflect's versions take a third (get) or fourth (set) receiver argument that forwards the original object the access happened through. Skipping it means any access made through a receiver other than the immediate target — most commonly a Proxy used as another object's prototype — silently writes to or reads from the wrong object.
Does Object.keys see the extra values a Proxy's get trap returns for missing properties?
No. A get trap only changes what a direct property read returns; it doesn't change what the target actually has. Object.keys, the in operator (without a has trap), and JSON.stringify all still report the target's real, own properties, so a default value invented in a get trap is invisible to all three unless you also add a matching has trap.
What happens if I keep using a proxy object after calling its revoke function?
Every operation on it — reading a property, writing one, calling a method — throws a TypeError immediately, with a message naming the specific operation that was attempted and the fact that the proxy has been revoked. The underlying target object is never affected; only the proxy wrapper stops working.
When would I reach for Proxy.revocable instead of a plain new Proxy?
Use it when the proxy's lifetime needs to end on command — handing a module a wrapped object that should stop working once a session ends, a dependency is torn down, or a one-time token has been read. A plain Proxy has no way to disable itself later; Proxy.revocable hands back the proxy and a separate revoke function so the code holding the proxy and the code deciding when it expires can be different code.