How it works
ProxyCreate(target, handler) throws unless both are objects. It builds an object whose essential internal methods are the Proxy versions, and adds [[Call]] only if the target is callable and [[Construct]] only if it is a constructor. That decision is fixed at creation: it is why typeof of a proxy never changes.
Each proxy method follows the same shape: throw if revoked, read the trap with GetMethod(handler, name), forward to the target if the trap is undefined, otherwise call the trap with the target as the first argument, then validate the result. The [[Get]] check, for example, reads the target's own descriptor for the key. If it is a non-configurable, non-writable data property and the trap's value is not SameValue to it, it throws. If it is a non-configurable accessor with an undefined getter, the trap must return undefined.
The same pattern covers the rest. [[Set]] rejects a different value for a frozen property. [[HasProperty]] and [[Delete]] refuse to hide a non-configurable property, or any property on a non-extensible target. [[GetPrototypeOf]] must return the target's prototype when the target is non-extensible. [[IsExtensible]] must agree with the target, and [[PreventExtensions]] may return true only if the target really is non-extensible. [[OwnPropertyKeys]] and [[GetOwnProperty]] have the strictest checks, listed in the answer.
Edge cases
Invariants are checked after the trap, against the target at that moment. These are all caught TypeErrors from Node.js 24:
const frozen = Object.freeze({ a: 1 });
const attempts = {
ownKeys: () => new Proxy(frozen, { ownKeys: () => [] }),
has: () => new Proxy(frozen, { has: () => false }),
set: () => new Proxy({}, { set: () => false }),
construct: () => new Proxy(function () {}, { construct: () => 1 }),
};
for (const [name, make] of Object.entries(attempts)) {
const p = make();
try {
if (name === 'ownKeys') Object.keys(p);
if (name === 'has') 'a' in p;
if (name === 'set') p.x = 1;
if (name === 'construct') new p();
} catch (e) {
console.log(name, e.constructor.name);
}
}
// → ownKeys TypeError
// → has TypeError
// → set TypeError
// → construct TypeError
The set case is not an invariant but the strict-mode rule: [[Set]] returning false makes the assignment operator throw. Reflect.set(p, 'x', 1) returns false instead. new p() fails because the construct trap returned 1, not an object.
Revocation: const { proxy, revoke } = Proxy.revocable({ a: 1 }, {}). After revoke(), proxy.a throws TypeError ("Cannot perform 'get' on a proxy that has been revoked" in V8), while typeof proxy still works. Calling revoke() again is a no-op. This is how you give a plugin temporary access, or drop references so the target can be collected.
Slots: a proxy forwards property reads, but a built-in method reads internal slots from this directly. new Proxy(new Date(0), {}).getTime() throws "this is not a Date object." in V8. A class getter reading #x through a proxy throws, because a private name is looked up on the object itself, not through any trap. Binding methods to the target works for built-ins, and breaks identity for callers who expect this to be the proxy.
Real-world scenario
Reactive state is the usual interview scenario: a set trap notices writes and triggers updates. A minimal version:
function reactive(target, onChange) {
return new Proxy(target, {
set(t, key, value, receiver) {
const old = t[key];
const ok = Reflect.set(t, key, value, receiver);
if (ok && !Object.is(old, value)) onChange(key);
return ok;
},
});
}
const state = reactive({ count: 0, nested: { n: 1 } }, (key) => console.log('changed', key));
state.count++;
state.nested.n = 2;
// → changed count
Note the second assignment prints nothing: nested is the raw inner object. Forwarding with Reflect.set(…, receiver) and returning its boolean keeps the semantics of inheritance and non-writable properties (see the Reflect question). Validation, default values, negative array indexes, and logging proxies for tests follow the same shape.
Trade-offs
Proxies give full interception without changing the object's callers, but each intercepted operation runs JavaScript, and engines can't optimise property access through a proxy the way they do for plain objects. I measured 20 million o.n reads in Node.js 24.19: about 10–75 ms on a plain object, about 200 ms through an empty-handler proxy, and about 460 ms through a get trap forwarding to Reflect.get. Those numbers are one machine and one microbenchmark; the ratio is the point, so measure your own workload before deciding.
Proxies are also invisible to ===, WeakMap keys, structuredClone (which throws DataCloneError on a proxy in Node.js 24) and slot-based built-ins. Reach for getters, Object.defineProperty, or an explicit wrapper class when you only need to observe known keys. Use a Proxy when you need every key, in, delete, enumeration, or call interception.