Metaprogramming & Resource Management
Metaprogramming means code that customises how the language itself behaves: how your objects convert, iterate or respond to property access. This chapter covers well-known symbols, Proxy and Reflect (ES6), the new using declarations for automatic clean-up, and how JavaScript manages memory.
You will learn
- Well-known symbols:
Symbol.toPrimitive,toStringTag,hasInstance, and theSymbol.forregistry. Proxy: intercept property reads and writes,in, deletes and calls ES2015.Reflect: the default behaviour, as functions ES2015.using/await using,Symbol.disposeandDisposableStackES2026.- Garbage collection, common memory leaks,
WeakRefandFinalizationRegistryES2021.
Well-known symbols
The language looks up certain symbol-keyed methods on your objects to decide how to treat them. You've already met Symbol.iterator (Chapter 13). A few more:
| Symbol | Used by |
|---|---|
Symbol.iterator / Symbol.asyncIterator | for…of, spread / for await |
Symbol.toPrimitive | conversion to a number or string (+obj, `${obj}`, obj > 3) |
Symbol.toStringTag | Object.prototype.toString: [object Tag] |
Symbol.hasInstance | instanceof |
Symbol.dispose / Symbol.asyncDispose | using / await using (below) |
Proxy ES2015
A Proxy wraps a target object and lets a handler intercept fundamental operations through functions called traps. Any operation without a trap passes straight through to the target.
| Trap | Intercepts |
|---|---|
get(target, key, receiver) | proxy.key, proxy[key] |
set(target, key, value, receiver) | proxy.key = value (return true for success) |
has(target, key) | key in proxy |
deleteProperty(target, key) | delete proxy.key |
ownKeys(target) | Object.keys, for…in, Reflect.ownKeys |
apply / construct | calling the proxy / new proxy() (when the target is a function) |
Change tracking: the idea behind reactive frameworks
Vue's reactivity and many state libraries work this way: reading or setting a property through a proxy is what lets them know what to re-render.
⚠️ Pitfalls: proxies are slower than plain objects, so don't put them on hot paths. Traps must respect invariants; for example, get can't report a different value for a non-configurable, non-writable property, or the engine throws a TypeError. Private #fields don't work through a proxy whose target is a class instance, because this becomes the proxy.
Reflect ES2015
Reflect is a namespace of functions that perform the default version of each operation a proxy can trap, with matching names and arguments. Inside a trap, Reflect.xxx(...arguments) means "now do what would normally happen". Some Reflect methods are also handy on their own:
The last example shows the advantage over Object.defineProperty, which would throw on a frozen object; Reflect returns false instead.
Resource management: using ES2026
Files, database connections, locks and timers must be released even when an error occurs. Until now that meant try/finally. The new using declaration does it automatically: when the block ends, for any reason, the value's [Symbol.dispose]() method is called. Several resources are disposed in reverse order of declaration.
Before: try/finally
Now: using
await using, DisposableStack and errors
📝 Availability: using is supported in Node 24+ and current Chromium and Firefox. Check your targets; TypeScript (5.2+) can compile it down for older ones.
Memory management
JavaScript frees memory automatically with a garbage collector (GC). Modern engines use mark-and-sweep: starting from the roots (globals, the current call stack, active closures), the GC marks everything it can reach and frees the rest. So an object lives exactly as long as something reachable refers to it. Cycles aren't a problem: two objects that point only at each other are freed together.
Common leaks
| Leak | Why it stays reachable | Fix |
|---|---|---|
| Growing caches or Maps | the Map references every key and value forever | size limits, LRU eviction, or a WeakMap for per-object data |
| Forgotten timers | setInterval keeps its callback and closure alive | clearInterval; dispose with using |
| Event listeners | the emitter keeps the listener (and what it closes over) | removeEventListener, { signal } with AbortController, once |
| Accidental globals | sloppy-mode assignments create global properties | modules (always strict) |
| Closures holding big data | a long-lived function captures a large array | capture only what's needed |
WeakRef and FinalizationRegistry ES2021
A WeakRef points to an object without keeping it alive: deref() returns the object, or undefined once it's been collected. A FinalizationRegistry calls you back sometime after an object is collected. Their timing is up to the engine and may never happen, so use them only for optimisations like caches, never for program logic.
More examples
1. A read-only view with Proxy
2. Negative array indexes (a classic Proxy demo)
3. Timing every method call
4. A disposable timer
5. Revocable access
Recap
- Well-known symbols hook your objects into the language:
toPrimitivefor conversion,toStringTag,hasInstance, iteration and disposal. Proxytrapsget/set/has/deleteProperty/apply…; use it for validation, defaults, tracking and views, and mind the performance cost and invariants.Reflectgives the default behaviour, so forward to it from traps.using/await usingcall[Symbol.dispose]in reverse order at block exit;DisposableStackcollects clean-ups;SuppressedErrorkeeps both errors.- The GC frees unreachable objects. Leaks are references you forgot: caches, timers, listeners.
WeakRefis for optimisations only.
Exercises
Exercise 1 🟢 easy build it
Give a Temperature class a Symbol.toPrimitive method, so that `${t}` gives "21.5°C" and t + 1 gives 22.5.
Show solution
+ uses the "default" hint, which this method treats as numeric; template literals use "string".
Exercise 2 🟢 easy predict the output
What does this print?
Show solution
1 0 false. The get trap supplies a default of 0, but there's no has trap, so in checks the target normally.
Exercise 3 🟢 easy refactor it
Rewrite this try/finally with using.
Show solution
The resource provides [Symbol.dispose]; using calls it when the block exits, normally or by an exception.
Exercise 4 🟢 easy explain why
Why isn't a cycle (a.other = b; b.other = a;) a memory leak when nothing else references a or b?
Show solution
Mark-and-sweep starts from the roots. If neither object is reachable from a root, neither gets marked, and both are freed, whatever they point to among themselves. (Old reference-counting collectors couldn't do this.)
Exercise 5 🟡 medium build it
Write withDefaults(obj, defaults), which returns a Proxy that reads from obj and falls back to defaults. in should report both, and writes should go to obj only.
Show solution
No set trap is needed: the default set writes to the target.
Exercise 6 🟡 medium predict the output
In what order are the lines printed?
Show solution
inner, dispose 2, outer, dispose 3, dispose 1. Each block disposes its own resources when it exits, in reverse declaration order.
Exercise 7 🟡 medium build it · playground
Create playground/audit.js exporting audited(obj, log): a Proxy that records every property write and delete as { op, key, value } in the log array, while behaving normally otherwise.
Show solution
Each trap records the operation, then forwards to Reflect, so the order behaves exactly like a normal object.
Exercise 8 🟡 medium fix the bug
This Node script never exits. Why? Fix it, so that the interval stops after 3 ticks.
Show solution
An active interval keeps the event loop alive, and keeps its closure in memory. Clear it (or unref() it in Node) when it's done.
Exercise 9 🔴 hard explain why
Why should you call Reflect.get(target, key, receiver) in a get trap, rather than target[key], when the target has getters?
Show solution
A getter's this should be the object the property was read through, the receiver (usually the proxy, or an object inheriting from it). target[key] runs the getter with this = target, so property reads inside the getter bypass the proxy's traps. Reflect.get passes the receiver along correctly.
Exercise 10 🔴 hard mini-project · playground
Create playground/transaction.js with a transaction(state) function. It returns a disposable object exposing draft (a Proxy that records changes, without touching state) and commit(). When the using block ends without commit(), the changes are discarded and a rollback message is printed.
Show solution
The Proxy layers pending changes over the real object, and [Symbol.dispose] turns "forgot to commit" into an automatic rollback, the same pattern database libraries use.