Chapter 15 · Modules & Advanced Features

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 the Symbol.for registry.
  • Proxy: intercept property reads and writes, in, deletes and calls ES2015.
  • Reflect: the default behaviour, as functions ES2015.
  • using / await using, Symbol.dispose and DisposableStack ES2026.
  • Garbage collection, common memory leaks, WeakRef and FinalizationRegistry ES2021.

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:

SymbolUsed by
Symbol.iterator / Symbol.asyncIteratorfor…of, spread / for await
Symbol.toPrimitiveconversion to a number or string (+obj, `${obj}`, obj > 3)
Symbol.toStringTagObject.prototype.toString: [object Tag]
Symbol.hasInstanceinstanceof
Symbol.dispose / Symbol.asyncDisposeusing / 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.

TrapIntercepts
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 / constructcalling 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

LeakWhy it stays reachableFix
Growing caches or Mapsthe Map references every key and value foreversize limits, LRU eviction, or a WeakMap for per-object data
Forgotten timerssetInterval keeps its callback and closure aliveclearInterval; dispose with using
Event listenersthe emitter keeps the listener (and what it closes over)removeEventListener, { signal } with AbortController, once
Accidental globalssloppy-mode assignments create global propertiesmodules (always strict)
Closures holding big dataa long-lived function captures a large arraycapture 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: toPrimitive for conversion, toStringTag, hasInstance, iteration and disposal.
  • Proxy traps get/set/has/deleteProperty/apply…; use it for validation, defaults, tracking and views, and mind the performance cost and invariants.
  • Reflect gives the default behaviour, so forward to it from traps.
  • using/await using call [Symbol.dispose] in reverse order at block exit; DisposableStack collects clean-ups; SuppressedError keeps both errors.
  • The GC frees unreachable objects. Leaks are references you forgot: caches, timers, listeners. WeakRef is 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.

Official docs