Metaprogramming
Code that changes how ordinary-looking code behaves.
Symbol — a key that can never collide
Every Symbol() call creates a value that's unique, even
against another symbol created with the exact same description — it
exists specifically to be usable as a property key that can never
accidentally collide with a string key some other piece of code
happens to also use.
const id = Symbol("id");
const obj = { name: "Ana", [id]: 42 };
Object.keys(obj); // ["name"] — symbol keys are invisible to normal enumeration
obj[Symbol("id")]; // undefined — a DIFFERENT symbol, even with the identical description
obj[id]; // 42 — only the exact same symbol reference works
JS itself uses a handful of well-known symbols as hooks the engine calls automatically at specific moments — this is the actual mechanism behind several "special" behaviors from earlier chapters.
| Symbol | Called when |
|---|---|
Symbol.iterator | for...of, spread, or destructuring needs to walk the object's values |
Symbol.toPrimitive | the object is used where a primitive is needed — +obj, template interpolation, obj + "" |
Symbol.hasInstance | instanceof checks this object as the right-hand side |
Symbol.toStringTag | Object.prototype.toString.call(obj) builds its "[object X]" label |
class Money {
constructor(amount) { this.amount = amount; }
[Symbol.toPrimitive](hint) {
if (hint === "number") return this.amount;
if (hint === "string") return "$" + this.amount.toFixed(2);
return "Money(" + this.amount + ")";
}
}
const price = new Money(9.5);
console.log(+price); // "number" hint — what happens?
console.log(`${price}`); // "string" hint — what happens?
console.log(price + ""); // "default" hint — what happens?
9.5, then "$9.50", then
"Money(9.5)" — the exact same object gives three
different answers, because the engine tells
Symbol.toPrimitive which conversion it's
trying to do. This is the real mechanism behind why
+new Date() gives a timestamp while
`${new Date()}` gives a readable string — same object,
hint-aware conversion.
Iteration protocols, formally
Two related but separate contracts. An object is iterable if
it has a [Symbol.iterator]() method that returns an
iterator — and an iterator is just any object with a
.next() method that returns
{ value, done }. That's the entire protocol
for...of, spread, and destructuring are all built on —
which is exactly why the custom Range class below works
with every one of them for free, the moment it implements one method.
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
return current < end
? { value: current++, done: false }
: { value: undefined, done: true };
},
};
}
}
console.log([...new Range(1, 5)]); // what happens?
[1, 2, 3, 4] — spread never needed to know
Range exists as a concept. It only ever asked "does this
have Symbol.iterator?", called it, and kept calling
.next() until done came back
true. Generators are
just a shortcut for writing exactly this object without building it
by hand — every generator already implements this protocol for you.
Async iteration is the same shape with one difference:
[Symbol.asyncIterator]() instead, and
.next() returns a promise of
{ value, done }, which is what for await...of
knows how to unwrap.
Proxy and Reflect
A Proxy wraps an object and lets you intercept the
fundamental operations on it — get, set,
has, deleteProperty, and more — with your
own function, called a trap. Reflect is the
companion: the same set of operations, exposed as plain functions,
so a trap can perform the real default behavior after doing
its own work, instead of re-implementing it by hand.
const target = { name: "Ana", age: 29 };
const logged = new Proxy(target, {
get(obj, prop) {
console.log("GET", String(prop));
return Reflect.get(obj, prop); // the real, normal read
},
set(obj, prop, value) {
console.log("SET", String(prop), "=", value);
return Reflect.set(obj, prop, value); // the real, normal write
},
});
logged.name;
logged.age = 30;
Every single property access on logged — reads and
writes both — is now observable, without target itself
ever knowing it's being watched. This exact shape (intercept, log or
validate, then delegate to Reflect) is the whole
mechanism behind validation libraries, ORMs that track which fields
changed, and framework reactivity.
function createValidated(schema) {
return new Proxy({}, {
set(obj, prop, value) {
if (schema[prop] && typeof value !== schema[prop]) {
throw new TypeError(String(prop) + " must be a " + schema[prop]);
}
return Reflect.set(obj, prop, value);
},
});
}
const user = createValidated({ age: "number" });
user.age = "nope"; // throws immediately — invalid data can't even be assigned
Object.defineProperty vs Proxy — Vue 2 vs Vue 3
Before Proxy existed everywhere, reactive frameworks
used Object.defineProperty to turn each property into a
getter/setter pair that could track reads and notify on writes — this
was Vue 2's actual reactivity engine, property by property.
Object.defineProperty (Vue 2) |
Proxy (Vue 3) |
|
|---|---|---|
| New properties added later | invisible — never converted, needed a special Vue.set() | caught automatically — the trap fires for any key |
| Arrays | index writes and length changes needed special-cased method overrides | just works — array mutation is property access too |
| Setup cost | walks every property up front, recursively | wraps once — nested objects are wrapped lazily, on first access |
Invariants Proxy has to respect
A trap isn't a completely free rewrite of an object's behavior —
a handful of invariants are enforced by the engine no matter what a
trap tries to return, mostly around Object.freeze. A
get trap on a frozen, non-configurable, non-writable
property must return the real, actual value — returning
anything else throws a TypeError. This exists so
Object.freeze's guarantee from
two chapters back stays
a real guarantee, not something a misbehaving Proxy trap could
quietly undermine.
eval and new Function — and why almost never
eval("console.log(1 + 1)"); // runs in the CALLING scope — can read/write local variables
new Function("a", "b", "return a + b"); // runs in GLOBAL scope only — can't see any local variable
Both compile and run a string as code, and both come with the same three costs: the engine can't statically analyze code that doesn't exist yet at parse time, so it gets none of the optimization this whole chapter has been about; a strict Content-Security-Policy (covered next chapter) blocks them outright; and if that string ever contains anything derived from user input, it's arbitrary code execution, full stop — not a bug class, the actual worst case.
.try block on this page, and the whole practice
playground, really does run your code through
new Function(...) inside a Web Worker — that's not a
contradiction of the warning above, it's the actual legitimate use
case: a sandboxed worker with no DOM access, running code the reader
explicitly chose to execute, not untrusted input silently reaching
eval in a real production app.
Opens in the editor — write it, run it, and check it against real tests.