Part X — Modern JavaScript: deep, current, and fun
Parts I–VIII taught the language and the platform underneath it. Parts X–XV build on that with what the guide was missing until now. Part X covers the language features senior engineers use daily: metaprogramming, lazy iteration and streaming, and everything that landed in ES2023–ES2026. It ends with a puzzle chapter that tests whether you understand all of it.
Every code block in this Part was run in Chromium 153 (October 2026). Features that are not in every browser yet carry a support note.
53. Metaprogramming: Symbols, Proxy and Reflect
Metaprogramming is code that changes how other code behaves: how
objects convert to strings, what happens on property access, how for...of
walks them. JavaScript gives you three tools for this. Well-known
symbols are hooks the language calls. Proxies intercept operations on
an object. Reflect performs the default version of each operation.
Symbols: hooks the language calls for you
A Symbol is a unique, unforgeable property key. Libraries use them for
"hidden" keys that cannot collide with user data. The engine itself uses a
set of well-known symbols to ask your objects how to behave:
class Money {
constructor(cents, currency = "USD") { this.cents = cents; this.currency = currency; }
// How the object converts in `+`, template literals and comparisons
[Symbol.toPrimitive](hint) {
return hint === "number" ? this.cents / 100
: new Intl.NumberFormat("en-US", { style: "currency", currency: this.currency }).format(this.cents / 100);
}
// What Object.prototype.toString (and DevTools) call it
get [Symbol.toStringTag]() { return "Money"; }
}
const price = new Money(12999);
`${price}`; // "$129.99" (hint "string")
+price; // 129.99 (hint "number")
price > new Money(5000); // true (hint "number" on both sides)
Object.prototype.toString.call(price); // "[object Money]"
// Implement Symbol.iterator and you get spread, destructuring and for...of.
const range = (from, to, step = 1) => ({
*[Symbol.iterator]() { for (let i = from; i < to; i += step) yield i; },
});
[...range(0, 10, 3)]; // [0, 3, 6, 9]
const [first, second] = range(5, 100); // 5, 6: destructuring pulls only two values
Symbol("id") === Symbol("id"); // false: every Symbol() is unique
Symbol.for("app.id") === Symbol.for("app.id"); // true: the global registry
| Well-known symbol | The language calls it when… | Real-world use |
|---|---|---|
Symbol.iterator |
for...of, spread, destructuring, Array.from |
collections, lazy ranges, tree walkers |
Symbol.asyncIterator |
for await...of |
paginated APIs, streams (§54) |
Symbol.toPrimitive |
+x, `${x}`, x > y |
money, dates, vectors |
Symbol.toStringTag |
Object.prototype.toString |
readable debug output |
Symbol.hasInstance |
x instanceof C |
duck-typed "interfaces" |
Symbol.dispose / Symbol.asyncDispose |
leaving a using block (§55) |
files, locks, subscriptions |
Proxy and Reflect
A Proxy wraps a target object with a handler. Each handler method
is a trap that intercepts one internal operation: get, set, has,
deleteProperty, ownKeys, apply (calls), construct (new), and six
more. Reflect has a function with the same name and arguments for every
trap, which performs the default behaviour. A trap therefore usually does its
extra work and then calls Reflect:
const logged = new Proxy({ a: 1 }, {
get(target, key, receiver) {
console.log(`read ${String(key)}`);
return Reflect.get(target, key, receiver); // the default behaviour, with the right `this`
},
});
logged.a; // logs "read a", returns 1
The receiver argument matters when getters are involved. Forwarding it
keeps this pointing at the proxy, so reads made inside a getter are
intercepted too. Most reactive libraries depend on that.
Build it: a reactive store in 45 lines
This is how Vue 3's reactivity works. A get trap tracks which effect
read which property. A set trap triggers exactly the effects that
depend on the property that changed. The bookkeeping is a
WeakMap<target, Map<key, Set<effect>>>. The WeakMap is what keeps it
leak-free: when a state object becomes garbage, its dependency map goes
with it (§50).
// reactive.js
const targetMap = new WeakMap(); // target -> Map(key -> Set(effect))
let activeEffect = null;
function track(target, key) {
if (!activeEffect) return;
let deps = targetMap.get(target);
if (!deps) targetMap.set(target, (deps = new Map()));
let effects = deps.get(key);
if (!effects) deps.set(key, (effects = new Set()));
effects.add(activeEffect);
}
function trigger(target, key) {
const effects = targetMap.get(target)?.get(key);
if (effects) [...effects].forEach((fn) => fn());
}
const proxies = new WeakMap(); // one proxy per object, so identity is stable
export function reactive(obj) {
if (proxies.has(obj)) return proxies.get(obj);
const proxy = new Proxy(obj, {
get(target, key, receiver) {
track(target, key);
const value = Reflect.get(target, key, receiver);
// Lazy deep reactivity: wrap nested objects only when someone reads them.
return value !== null && typeof value === "object" ? reactive(value) : value;
},
set(target, key, value, receiver) {
const old = target[key];
const hadKey = Object.hasOwn(target, key);
const ok = Reflect.set(target, key, value, receiver);
if (!hadKey || !Object.is(old, value)) {
trigger(target, key);
if (Array.isArray(target) && key !== "length") trigger(target, "length");
}
return ok; // a set trap MUST return true on success
},
deleteProperty(target, key) {
const ok = Reflect.deleteProperty(target, key);
trigger(target, key);
return ok;
},
});
proxies.set(obj, proxy);
return proxy;
}
export function effect(fn) {
const run = () => {
const prev = activeEffect;
activeEffect = run;
try { fn(); } finally { activeEffect = prev; }
};
run();
return run;
}
const cart = reactive({ items: [{ name: "Keyboard", price: 129, qty: 1 }], coupon: null });
effect(() => {
const total = cart.items.reduce((sum, i) => sum + i.price * i.qty, 0);
console.log(`total: $${cart.coupon ? total * 0.9 : total}`);
}); // total: $129
cart.items[0].qty = 2; // total: $258
cart.items.push({ name: "Mouse", price: 49, qty: 1 }); // total: $307
cart.coupon = "SAVE10"; // total: $276.3
None of the mutations mentions the effect, yet each one reruns exactly the code that depends on it. This is fine-grained reactivity (§33). §71 builds the other major approach, signals, and compares them.
Real-world example. Vue 3's
reactive()is this design plus effect scheduling, cleanup of stale dependencies, and special handling for collections. Immer (behind Redux Toolkit) uses Proxies differently: you "mutate" a draft, the traps record every write, and Immer builds a new immutable object from the record. Comlink proxies a Web Worker, soawait api.resize(img)becomes apostMessageround trip (§41). MobX and Valtio are both Proxy stores.
Where Proxies bite
class Account {
#balance = 100; // private fields live on the real object only
get balance() { return this.#balance; }
}
const p = new Proxy(new Account(), {});
p.balance; // TypeError: Cannot read private member #balance from an object whose class did not declare it
// Fix: run getters and methods against the target, not the proxy.
const fixed = new Proxy(new Account(), {
get(target, key) {
const v = Reflect.get(target, key, target);
return typeof v === "function" ? v.bind(target) : v;
},
});
fixed.balance; // 100
// Same story for built-ins with internal slots: Map, Set, Date, Promise.
new Proxy(new Map([["a", 1]]), {}).get("a");
// TypeError: Method Map.prototype.get called on incompatible receiver #<Map>
- Identity.
proxy !== target. If code keeps the raw object somewhere (aSet, a cache), changes made through it are invisible to the traps. Vue'stoRaw()and theproxiesWeakMap above exist for this. - Private fields and internal slots. Inside the proxy,
thisis the proxy, and the proxy has neither#privatefields nor a Map's internal storage. Reactive libraries ship special collection handlers for this. - Invariants. A trap cannot lie about a non-configurable, non-writable
property. If it tries, the engine throws a
TypeError. - Cost. Every access goes through a function call and cannot use V8's inline caches (§49). Don't wrap hot numeric data such as a 1M-element array. Use typed arrays and explicit updates there.
🎯 Challenge. Extend
reactive()with acomputed(fn)that caches its value and recomputes only after a dependency changes. Hint: a computed is an effect that marks itself dirty instead of rerunning, plus a getter that tracks like a property. Then compare with §71's signals.