Metaprogramming: Proxies, Reflect, Iteration Protocols, and Invariants
Proxies expose the internal methods to user code, but only within a fence of invariants that keep the rest of the language sound.
A Proxy is an exotic object whose thirteen internal methods dispatch to handler traps. The trap surface is exactly the internal-method table from chapter 1, `Reflect` is the identity implementation of that table, and the invariants the engine enforces after each trap are what allow the specification's other algorithms to keep assuming things about objects. The iteration protocols and tagged templates round out the language's hook points.
In this chapter
The Proxy model: one trap per internal method
new Proxy(target, handler) creates a Proxy exotic object with [[ProxyTarget]] and [[ProxyHandler]] slots and no own properties of its own. Each of its internal methods is defined as: look up the trap on the handler (GetMethod(handler, "get")); if absent, forward to the same internal method on the target; if present, call it with the target and the arguments, then validate the result against the target. The trap names are the internal method names with the brackets removed: getPrototypeOf, setPrototypeOf, isExtensible, preventExtensions, getOwnPropertyDescriptor, defineProperty, has, get, set, deleteProperty, ownKeys, apply, construct. There is no trap for anything that is not an internal method: no trap for typeof (it reports "function" if the target is callable), no trap for ===, and, since ES2016, no enumerate trap (for…in is implemented via ownKeys and getOwnPropertyDescriptor).
Reflect is the mirror image: Reflect.get(target, key, receiver) is target.[[Get]](key, receiver) with no coercion or sugar. Its methods have the same signatures as the traps so a trap can forward with Reflect[trapName](target, ...args). They also expose behaviour the operator forms hide: Reflect.set returns the boolean success flag instead of throwing-or-not depending on strictness; Reflect.defineProperty returns false instead of throwing; Reflect.ownKeys returns strings and symbols including non-enumerables; Reflect.construct accepts newTarget; Reflect.apply cannot be hijacked by a shadowed Function.prototype.apply.
const trace = [];
const target = { a: 1, get g() { return this.a; } };
Object.setPrototypeOf(target, { inherited: true });
const p = new Proxy(target, new Proxy({}, {
get(_, trap) { // a meta-proxy: intercept every trap lookup to log it
return (t, ...args) => { trace.push(trap + "(" + args.slice(0, 1).map(String).join("") + ")"); return Reflect[trap](t, ...args); };
},
}));
const run = (label, f) => { trace.length = 0; f(); console.log(label.padEnd(28), "->", trace.join(", ")); };
run("p.a", () => p.a);
run("p.inherited", () => p.inherited);
run("p.g (getter, receiver=p)", () => p.g);
run("'a' in p", () => "a" in p);
run("p.b = 2", () => { p.b = 2; });
run("delete p.b", () => { delete p.b; });
run("Object.keys(p)", () => Object.keys(p));
run("{...p}", () => ({ ...p }));
run("JSON.stringify(p)", () => JSON.stringify(p));
run("for (k in p) {}", () => { for (const k in p) void k; });
run("Object.getPrototypeOf(p)", () => Object.getPrototypeOf(p));
run("p instanceof Object", () => p instanceof Object);
run("typeof p", () => typeof p);
run("p === p", () => p === p);p.g traces get(g), get(a): the getter runs with this = the proxy (the receiver), so its this.a re-enters the get trap. p.b = 2 traces set(b) followed by getOwnPropertyDescriptor(b) and defineProperty(b): OrdinarySet, finding no existing property, creates the new one on the receiver, which is the proxy, through the proxy's own internal methods.
Invariants: what a trap is not allowed to lie about
If traps could return anything, no algorithm in the specification could rely on any object property. Object.freeze would be meaningless if a getOwnPropertyDescriptor trap could report a frozen property as configurable. So each Proxy internal method, after calling the trap, checks its result against the target's current state and throws TypeError on inconsistency. The invariants are chosen to be exactly the facts that other parts of the language treat as stable: non-configurability, non-extensibility, and non-writable data property values.
| Trap | Invariant enforced (result must be consistent with the target) |
|---|---|
| getPrototypeOf | Must return an Object or null. If the target is non-extensible, must return exactly the target's prototype. |
| setPrototypeOf | If it returns true and the target is non-extensible, the new prototype must equal the target's current prototype. |
| isExtensible | Must return the same boolean as Object.isExtensible(target). |
| preventExtensions | May return true only if the target is actually non-extensible afterwards. |
| getOwnPropertyDescriptor | May not report a property as non-existent if it exists as non-configurable on the target, or if the target is non-extensible. May not report a property as existing if the target is non-extensible and lacks it. May not report configurable: false unless the target's property is non-configurable (and for writable: false, the target's must also be non-writable). The returned descriptor must be compatible with the target's (IsCompatiblePropertyDescriptor). |
| defineProperty | May not add a property to a non-extensible target. May not report success for a non-configurable definition unless the target's property is non-configurable (and non-writable when the descriptor is). |
| has | May not hide (return false for) a non-configurable own property of the target, nor any own property if the target is non-extensible. |
| get | If the target has a non-configurable, non-writable data property, the trap must return the same value (SameValue). If it has a non-configurable accessor with undefined getter, must return undefined. |
| set | If the target has a non-configurable, non-writable data property, may report success only for the same value. If it has a non-configurable accessor with undefined setter, may not report success. |
| deleteProperty | May not report success for a non-configurable own property of the target, nor for any own property if the target is non-extensible. |
| ownKeys | Must return a List of unique Strings/Symbols; must include every non-configurable own key of the target; if the target is non-extensible, must return exactly the target's keys. |
| apply, construct | No invariants beyond construct returning an Object. |
const frozen = Object.freeze({ x: 1 });
const liar = new Proxy(frozen, {
get: () => "lie",
has: () => false,
ownKeys: () => [],
getOwnPropertyDescriptor: () => undefined,
getPrototypeOf: () => Array.prototype,
});
for (const [label, f] of [
["get on non-writable, non-configurable", () => liar.x],
["has hiding non-configurable", () => "x" in liar],
["ownKeys omitting non-configurable", () => Object.keys(liar)],
["gOPD hiding non-configurable", () => Object.getOwnPropertyDescriptor(liar, "x")],
["getPrototypeOf on non-extensible", () => Object.getPrototypeOf(liar)],
]) {
try { f(); console.log(label, "->", "allowed?!"); }
catch (e) { console.log(label.padEnd(40), "->", e.constructor.name); }
}
// The same lies are permitted when the target is ordinary and extensible:
const honestTarget = { x: 1 };
const freeLiar = new Proxy(honestTarget, { get: () => "lie", has: () => false, ownKeys: () => [] });
console.log("extensible target:", freeLiar.x, "x" in freeLiar, Object.keys(freeLiar));The invariants have a practical consequence for the classic use case of a proxy as a virtual object (a remote object stub, an ORM row, a reactive store). Such a proxy usually wraps an empty extensible target and is free to invent properties. But the moment someone calls Object.freeze on the proxy, preventExtensions runs on the target and the proxy is now bound to report exactly the target's (empty) key set. Reactive frameworks therefore either forbid freezing their proxies or keep the target in sync with the virtual view (Vue 3 uses the original object as the target for exactly this reason).
Membranes and revocation
A membrane is a pair of proxy factories that wrap every object crossing a boundary in both directions, maintaining two WeakMaps so that the same underlying object always maps to the same proxy (preserving identity within each side). Every trap unwraps its arguments, forwards, and re-wraps results; apply and construct wrap this, arguments, and return values. Membranes are how realm-isolation libraries (SES/Endo, the Realms shim), DevTools object inspection, and some sandboxing tools control what code on one side can observe of the other. Proxy.revocable completes the picture: calling the returned revoke function sets [[ProxyHandler]] and [[ProxyTarget]] to null, after which every internal method throws TypeError, providing capability revocation without having to find every holder of the reference.
The iteration protocols and IteratorClose
GetIterator(obj, sync) calls obj[@@iterator]() and wraps the result in an Iterator Record { [[Iterator]], [[NextMethod]], [[Done]] }. Two details are routinely missed. First, the next method is read once, when the record is created (GetV(iterator, "next")), so replacing it.next mid-iteration has no effect on a running for…of. Second, every consumer that may stop early (for…of on break/return/throw, array destructuring that does not exhaust the iterator, Array.from and the Map/Set constructors when a callback or an entry throws, Promise.all when calling the constructor's resolve throws, yield* when the outer generator is closed) is required to call IteratorClose, which does GetMethod(iterator, "return") and calls it if present. For a generator this resumes the body with a return completion so finally blocks run; for a hand-written iterator, the return method is where resources are released. If the loop body threw and return() also throws, the body's exception wins.
function resource(name) {
let i = 0;
return {
[Symbol.iterator]() { return this; },
next() { return i < 3 ? { value: name + i++, done: false } : { value: undefined, done: true }; },
return(v) { console.log(" return() called for", name, "at i =", i); return { value: v, done: true }; },
};
}
console.log("for-of with break:");
for (const x of resource("break")) { if (x.endsWith("1")) break; }
console.log("for-of with throw (return() still called, original error propagates):");
try { for (const x of resource("throw")) { throw new Error("body failed at " + x); } } catch (e) { console.log(" caught:", e.message); }
console.log("array destructuring (takes 2, closes):");
const [a, b] = resource("destructure"); void a; void b;
console.log("full consumption does NOT call return():");
for (const x of resource("complete")) void x;
console.log("spread does NOT call return() on completion, only on abrupt exit:");
[...resource("spread")];
console.log("Map constructor closes on a bad entry:");
try { new Map(resource("map")); } catch (e) { console.log(" caught:", e.constructor.name, "(entries must be objects)"); }ES2025's Iterator helpers (Iterator.prototype.map/filter/take/drop/flatMap/reduce/toArray/forEach/some/every/find, plus Iterator.from) add lazy combinators directly on the iterator prototype and close the underlying iterator correctly on early termination. take(n) in particular calls return() on the source once n items have been produced, something that a hand-written for…of with a counter and break also does, but that a naive [...it].slice(0, n) does not.
The remaining well-known-symbol hooks
- `Symbol.hasInstance`:
InstanceofOperator(V, target)first doesGetMethod(target, @@hasInstance)and calls it if present; only otherwise does it fall back toOrdinaryHasInstance(the prototype walk).Function.prototype[@@hasInstance]is the default and is non-writable, non-configurable so it cannot be globally hijacked; a class can definestatic [Symbol.hasInstance](x)to implement structural checks. - `Symbol.toStringTag`:
Object.prototype.toStringreads it after the built-in brand checks, soObject.prototype.toString.call(x)is a brand check for built-ins with internal slots (Array, Error, Date, RegExp, arguments, callable) and an easily-forged label for everything else. - `Symbol.isConcatSpreadable`:
Array.prototype.concatspreads an argument if this symbol is truthy, or if absent and IsArray is true. It is how array-likes opt in and arrays opt out. - `Symbol.match` / `matchAll` / `replace` / `search` / `split`:
String.prototype.replaceetc. callGetMethod(searchValue, @@replace)before coercing anything, so any object with these methods, not only a RegExp, can be passed.IsRegExpalso checks@@matchbefore[[RegExpMatcher]], so an object with[Symbol.match]: falseis treated as a string pattern even if it is a RegExp. - `Symbol.asyncIterator`: consulted by
for awaitand byGetIterator(obj, async). If absent,CreateAsyncFromSyncIteratorwraps a sync iterator, awaiting eachvalue.
Tagged templates: a call with a cached, frozen strings array
A tagged template, tag followed by a template literal with one substitution x, evaluates to tag(strings, x) where strings is a frozen array ["a", "b"] with a frozen raw property holding the uninterpreted text. The array is produced by GetTemplateObject, which looks it up in the realm's [[TemplateMap]] keyed by the Parse Node: the same template literal site yields the same array object on every evaluation, while two textually identical templates at different source positions yield different arrays. This gives tag functions a stable identity to memoise on, which is exactly what lit-html, graphql-tag, and SQL template libraries do: parse once per call site, cache by strings in a WeakMap, and only substitute values on subsequent calls. Because raw preserves escapes, String.raw can return backslashes verbatim, and tag functions can implement their own escaping conventions (a tag can even accept otherwise-invalid escape sequences like \unicode, which appear as undefined in the cooked array; ES2018 relaxed the grammar for exactly this).
const seen = new WeakMap();
function tag(strings, ...values) {
const first = !seen.has(strings);
if (first) seen.set(strings, { compiledAt: Date.now() });
return (first ? "compiled" : "cached ") + " | frozen=" + Object.isFrozen(strings) + " | raw=" + JSON.stringify(strings.raw);
}
for (let i = 0; i < 3; i++) console.log("site A:", tag`x${i}\n`); // same call site -> same strings array
console.log("site B:", tag`x${0}\n`); // textually identical, different site
console.log("String.raw:", String.raw`C:\Users\name`);
console.log("invalid escape in a tagged template is allowed:", ((s) => s[0] === undefined ? "cooked undefined, raw=" + s.raw[0] : s[0])`\unicode`);Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Metaobject protocols | Gregor Kiczales, Jim des Rivières & Daniel Bobrow, The Art of the Metaobject Protocol (1991) | Exposing the object model's internal operations as overridable methods; Proxy traps are the internal methods made programmable. |
| Proxies with invariants | Tom Van Cutsem & Mark S. Miller, Proxies: Design Principles for Robust Object-oriented Intercession APIs (2010); Trustworthy Proxies (2013) (2013) | The trap-per-internal-method design and the invariant checks that keep frozen objects and non-configurable properties trustworthy. |
| Iteration protocols | Barbara Liskov (CLU); Python 2.2 iterators, CLU Reference Manual (1979); PEP 234 (2001) (2001) | An object-level protocol (a next method returning a result record) instead of language-level loop constructs; ES2015 added return() for early exit. |
| Symbols as unforgeable property keys | Allen Wirfs-Brock, Dave Herman, Mark Miller (TC39), ES2015 Symbol type and well-known symbols (2015) | Solved the problem of adding protocol hooks (@@iterator, @@toPrimitive) to objects without colliding with existing string-keyed properties. |
| Tagged templates | Mike Samuel, Allen Wirfs-Brock (TC39), ES2015 template literals (from the E language's quasi-literals) (2015) | Per-call-site cached strings arrays give DSL libraries a stable identity to memoise on; the design came from E's quasi-literals. |
Primary sources
- ECMA-262, §10.5 Proxy Object Internal Methods and Internal Slots
- ECMA-262, §6.1.7.3 Invariants of the Essential Internal Methods
- ECMA-262, §28.1 The Reflect Object
- ECMA-262, §7.4 Operations on Iterator Objects (GetIterator, IteratorClose)
- ECMA-262, §13.2.8.4 GetTemplateObject
- Tom Van Cutsem: Membranes in JavaScript