A3 · Part 3 · Advanced
Metaprogramming
Code that changes how ordinary-looking code behaves.
10On this page
- Symbol — a key that can never collide
- Iteration protocols, formally
- Property descriptors — what a property really is
- Proxy and Reflect
- Object.defineProperty vs Proxy — Vue 2 vs Vue 3
- Invariants Proxy has to respect
- WeakRef and FinalizationRegistry — watching garbage collection happen
- using — deterministic cleanup, without a manual try/finally
- Decorators — declarative behavior on a class or its members
- eval and new Function — and why almost never
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.
Property descriptors — what a property really is
const user = {};
Object.defineProperty(user, "id", { value: 7, enumerable: true });
Object.getOwnPropertyDescriptor(user, "id");
// { value: 7, writable: false, enumerable: true, configurable: false }
user.id = 8; // silently ignored — throws in strict mode
delete user.id; // false — not configurable
Every property carries four attributes. value is the data;
writable says whether it can be reassigned; enumerable says
whether Object.keys, for...in and spread see it;
configurable says whether it can be deleted or redefined. An accessor
property replaces value and writable with a
get and a set. The trap is the defaults:
obj.x = 1 creates a property with all three flags true,
but defineProperty leaves any flag you omit false.
Object.freezeis simply "set every own property to non-writable and non-configurable, and stop adding new ones".- Class methods are non-enumerable, which is why they do not show up when you log or spread an instance.
- Copying loses descriptors. Spread and
Object.assigncall getters and copy the resulting value. To copy the property itself, getter included:Object.defineProperties({}, Object.getOwnPropertyDescriptors(source)).
These flags are what Proxy's invariants (below) protect: a trap may not lie about a property that is non-configurable and non-writable.
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.
Dry run: which trap fires, with what arguments
| Step | Code | Trap fired | Arguments | Returns |
|---|---|---|---|---|
| 1 | logged.name | get | obj = target, prop = "name" | logs "GET name", then Reflect.get(obj, "name") → "Ana" |
| 2 | logged.age = 30 | set | obj = target, prop = "age", value = 30 | logs "SET age = 30", then Reflect.set(obj, "age", 30) → true; target.age is now 30 |
Neither trap invents new behavior — each one observes the operation, then hands it straight to the matching Reflect function to actually perform it. Skip that call, or return the wrong value from a trap, and the proxy stops behaving like a normal object: a get trap that forgets to return makes every property read come back undefined.
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
Error handling & debugging stays
a real guarantee, not something a misbehaving Proxy trap could
quietly undermine.
WeakRef and FinalizationRegistry — watching garbage collection happen
let obj = { data: "large payload" };
const ref = new WeakRef(obj);
console.log(ref.deref()); // { data: "large payload" } — still alive, obj still references it
obj = null; // the only strong reference is gone
// at some LATER point, once GC actually runs:
console.log(ref.deref()); // undefined — the object is gone, and deref says so
WeakRef holds a reference that doesn't itself keep an
object alive — a step further than
WeakMap, which at least ties the
weak reference to a key you still hold.
FinalizationRegistry goes one step further still:
register a callback that runs, at some unpredictable point on the
engine's own schedule, after an object has actually been collected.
const registry = new FinalizationRegistry((heldValue) => {
console.log("cleaned up:", heldValue);
});
registry.register(obj, "obj's id or label");
⚠ Never build correctness on either of these
Both exist for optimization and diagnostics only — freeing an
external resource (a file handle, a WASM buffer) as a best-effort
backstop, or a memory profiler watching what actually gets collected.
The specification explicitly does not guarantee a finalizer ever runs
at all, let alone promptly — an engine can delay it indefinitely, or
skip it entirely at page-unload. Real cleanup that has to happen
belongs in an explicit method call, or the
using
declaration below — never a finalizer.
using — deterministic cleanup, without a manual try/finally
class FileHandle {
constructor(name) {
this.name = name;
console.log("opened", name);
}
[Symbol.dispose]() {
console.log("closed", this.name);
}
}
function readConfig() {
using file = new FileHandle("config.json"); // disposed automatically at the end of THIS block
console.log("reading", file.name);
} // [Symbol.dispose]() runs HERE, even if an error was thrown above
readConfig();
// opened config.json
// reading config.json
// closed config.json
using is a declaration, like const, that
additionally calls [Symbol.dispose]() on the value the
instant its scope ends — normal exit, an early return, or
an exception, all equally. It's the same guarantee
try/finally gives, without the indentation and without
anyone forgetting to write the finally block. An
await using variant exists for resources that need an
asynchronous close ([Symbol.asyncDispose]()) — a database
connection, a browser lock.
⚠ Check the target here too
using is newer than most of the language features in
this chapter — it landed later than Proxy or well-known
symbols, and older browsers and Node versions don't parse it at all.
Confirm the actual runtime and bundler target before reaching for it,
the same check the decorators section below asks for.
Decorators — declarative behavior on a class or its members
function logged(originalMethod, context) {
const name = String(context.name);
return function (...args) {
console.log("calling", name, "with", args);
return originalMethod.call(this, ...args);
};
}
class Api {
@logged
fetchUser(id) {
return { id, name: "Ana" };
}
}
new Api().fetchUser(1); // what happens?
Logs "calling fetchUser with [1]", then returns the user
as normal — the decorator wraps the original method without its own
body ever changing. A decorator is a function that receives the thing
it's decorating (a method, a field, or a whole class) plus a
context object describing it, and returns a replacement.
It's the same idea Angular and NestJS have used for years —
@Component, @Injectable — now standardized
into the language itself rather than requiring a compiler transform to
approximate it.
⚠ Check the target before reaching for a decorator
Decorators reached Stage 3 and shipped in real engines only recently —
confirm the runtime and bundler target actually support the current
proposal before using them. TypeScript's older
experimentalDecorators flag implements a different,
earlier, incompatible version of the same idea.
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 in Security) 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.
⚠ This site's own code runner uses new Function
Every runnable example on this site, 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.
Interview answer, one sentence
"Symbols are collision-free keys, the iteration protocols make anything usable with for...of and spread, property descriptors control what a property may do, and Proxy with Reflect intercepts operations — bound by invariants that stop a trap lying about a non-configurable property."
Before you move on
- Explain why
obj[Symbol("id")]returnsundefinedeven when the new symbol has the same description as an existing key. - Explain the iteration protocol (
Symbol.iterator→ an iterator whose.next()returns{ value, done }), and why it gets a classfor...of, spread, and destructuring for free. - Trace which trap fires, with what arguments, for a read versus a write on a logging Proxy, and say why the trap delegates to
Reflectinstead of skipping it. - Explain the invariant that stops a
gettrap from lying about a frozen, non-configurable, non-writable property. - Explain why
WeakRefandFinalizationRegistryare unsuitable for real cleanup, and what guaranteeusingand[Symbol.dispose]give you instead.
Opens in the editor: write it, run it, and check it against real tests.
All 31 for this level →Finished reading?
0 of 41 chapters read in this series