JavaScript · lesson 29 of 32
Beginner
13- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Regular Expressions in JavaScript
- 10 Arrays in JavaScript
- 11 Objects in JavaScript
- 12 Map, Set, WeakMap, and WeakSet in JavaScript
- 13 JSON in JavaScript: stringify, parse, and What Gets Lost
Intermediate
15- 14 Error Handling in JavaScript
- 15 Dates and Times in JavaScript: Date Pitfalls and Temporal
- 16 Object-Oriented Programming in JavaScript
- 17 Prototypes and Prototype Chain in JavaScript
- 18 Closures in JavaScript
- 19 this, call, apply, and bind in JavaScript
- 20 JavaScript Event Loop Explained
- 21 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 22 Iterators and Generators in JavaScript
- 23 JavaScript Modules: ESM and CommonJS
- 24 DOM Manipulation in JavaScript
- 25 Browser Web APIs in JavaScript
- 26 Files, Streams, and the Command Line in Node.js
- 27 Testing JavaScript with Jest and Vitest
- 32 JavaScript Interview Preparation: Top 35 Questions
Advanced
4Also part of JavaScript
1Proxy 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
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.