JS Internals
Chapter 10SpecEngine4 runnable probes

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
  1. The Proxy model: one trap per internal method
  2. Invariants: what a trap is not allowed to lie about
  3. Membranes and revocation
  4. The iteration protocols and IteratorClose
  5. The remaining well-known-symbol hooks
  6. Tagged templates: a call with a cached, frozen strings array

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.

ProbeWhich internal methods does each operation invoke?
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.

TrapInvariant enforced (result must be consistent with the target)
getPrototypeOfMust return an Object or null. If the target is non-extensible, must return exactly the target's prototype.
setPrototypeOfIf it returns true and the target is non-extensible, the new prototype must equal the target's current prototype.
isExtensibleMust return the same boolean as Object.isExtensible(target).
preventExtensionsMay return true only if the target is actually non-extensible afterwards.
getOwnPropertyDescriptorMay 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).
definePropertyMay 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).
hasMay not hide (return false for) a non-configurable own property of the target, nor any own property if the target is non-extensible.
getIf 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.
setIf 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.
deletePropertyMay not report success for a non-configurable own property of the target, nor for any own property if the target is non-extensible.
ownKeysMust 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, constructNo invariants beyond construct returning an Object.
ProbeInvariant violations are TypeErrors after the trap returns
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.

ProbeIteratorClose is called by every early-exiting consumer
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 does GetMethod(target, @@hasInstance) and calls it if present; only otherwise does it fall back to OrdinaryHasInstance (the prototype walk). Function.prototype[@@hasInstance] is the default and is non-writable, non-configurable so it cannot be globally hijacked; a class can define static [Symbol.hasInstance](x) to implement structural checks.
  • `Symbol.toStringTag`: Object.prototype.toString reads it after the built-in brand checks, so Object.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.concat spreads 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.replace etc. call GetMethod(searchValue, @@replace) before coercing anything, so any object with these methods, not only a RegExp, can be passed. IsRegExp also checks @@match before [[RegExpMatcher]], so an object with [Symbol.match]: false is treated as a string pattern even if it is a RegExp.
  • `Symbol.asyncIterator`: consulted by for await and by GetIterator(obj, async). If absent, CreateAsyncFromSyncIterator wraps a sync iterator, awaiting each value.

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

ProbeCall-site identity of the strings array
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`);
Origin

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.

MechanismOriginPath into JavaScript
Metaobject protocolsGregor 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 invariantsTom 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 protocolsBarbara 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 keysAllen 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 templatesMike 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