Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Advanced/A3
31 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSA3 · Part 3 · Advanced

Metaprogramming

Code that changes how ordinary-looking code behaves.

10 min read10 sections2 exercises
31 of 41

In this chapter

  • 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

and 5 more

about 10 min
10On this page
  1. Symbol — a key that can never collide
  2. Iteration protocols, formally
  3. Property descriptors — what a property really is
  4. Proxy and Reflect
  5. Object.defineProperty vs Proxy — Vue 2 vs Vue 3
  6. Invariants Proxy has to respect
  7. WeakRef and FinalizationRegistry — watching garbage collection happen
  8. using — deterministic cleanup, without a manual try/finally
  9. Decorators — declarative behavior on a class or its members
  10. 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.iteratorfor...of, spread, or destructuring needs to walk the object's values
Symbol.toPrimitivethe object is used where a primitive is needed — +obj, template interpolation, obj + ""
Symbol.hasInstanceinstanceof checks this object as the right-hand side
Symbol.toStringTagObject.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.freeze is 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.assign call 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

StepCodeTrap firedArgumentsReturns
1logged.namegetobj = target, prop = "name"logs "GET name", then Reflect.get(obj, "name") → "Ana"
2logged.age = 30setobj = target, prop = "age", value = 30logs "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 laterinvisible — never converted, needed a special Vue.set()caught automatically — the trap fires for any key
Arraysindex writes and length changes needed special-cased method overridesjust works — array mutation is property access too
Setup costwalks every property up front, recursivelywraps 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")] returns undefined even 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 class for...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 Reflect instead of skipping it.
  • Explain the invariant that stops a get trap from lying about a frozen, non-configurable, non-writable property.
  • Explain why WeakRef and FinalizationRegistry are unsuitable for real cleanup, and what guarantee using and [Symbol.dispose] give you instead.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 9 testsA Fibonacci class you can spreadadvanced
  • 9 testsAn array that rejects non-positive numbersadvanced
All 31 for this level →
A3

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. A2Previous · A2Advanced async
  2. A3you are here
  3. Next · A4Types & dataWhat happens once a number, a string, or a file gets big or exotic enough to need its own type.5 min read

01On this page

10 sections · 0%

  1. 01Symbol — a key that can never collide
  2. 02Iteration protocols, formally
  3. 03Property descriptors — what a property really is
  4. 04Proxy and Reflect
  5. 05Object.defineProperty vs Proxy — Vue 2 vs Vue 3
  6. 06Invariants Proxy has to respect
  7. 07WeakRef and FinalizationRegistry — watching garbage collection happen
  8. 08using — deterministic cleanup, without a manual try/finally
  9. 09Decorators — declarative behavior on a class or its members
  10. 10eval and new Function — and why almost never

[] chapters · t top