JavaScriptMetaprogramming

Meta-programming in JavaScript

Meta-programming is writing code that inspects or changes other code at runtime. JavaScript exposes more meta-programming surface than most scripting languages — Proxy, Reflect, Symbol-named hooks, decorators — and reactive libraries, ORMs and serialisers all rely on it. You don't need to write meta-programming every day, but knowing the tools is a step up in understanding how the libraries you use are built.

What JavaScript exposes
  • Proxy — intercept fundamental operations on an object (get, set, has, delete, call, construct).

  • Reflect — perform those same operations as plain function calls.

  • Symbol — a primitive used to define hidden or protocol property keys.

  • Well-known Symbols — built-in hooks (Symbol.iterator, Symbol.toPrimitive, etc.) that change how objects behave with language constructs.

  • Object.defineProperty and descriptors — fine control over how a property reads, writes and enumerates.

  • Decorators — a syntax for wrapping classes and class members at definition time (now stable in TypeScript and modern engines).

Well-known Symbols

The language reserves a handful of symbols that act as opt-in hooks. Implement them on your object and it integrates with built-in syntax.

Symbol.iterator — make your object iterable

JS
class Range {
  constructor(start, end) { this.start = start; this.end = end; }
  [Symbol.iterator]() {
    let i = this.start;
    const end = this.end;
    return {
      next: () => i < end ? { value: i++, done: false } : { value: undefined, done: true },
    };
  }
}

for (const n of new Range(1, 4)) console.log(n);
console.log([...new Range(10, 13)]);
1
2
3
[ 10, 11, 12 ]
  • Symbol.iterator — drives for..of, spread, destructuring.

  • Symbol.asyncIterator — same idea, for for await..of.

  • Symbol.toPrimitive — choose how your object converts to number/string.

  • Symbol.hasInstance — customise instanceof.

  • Symbol.toStringTag — what shows up in Object.prototype.toString.call(x).

Property descriptors

Every property has a descriptor with flags: writable, enumerable, configurable, plus either value or a get/set pair. Object.defineProperty writes one; Object.getOwnPropertyDescriptor reads one.

JS
const obj = {};

Object.defineProperty(obj, "id", {
  value: 42,
  writable: false,
  enumerable: false,
  configurable: false,
});

obj.id;                  // 42
obj.id = 99;             // silently ignored (TypeError in strict mode)
Object.keys(obj);        // []  (not enumerable)

Library tricks like "make this property look read-only but compute it lazily" come straight out of this API.

Proxy and Reflect — the heavy machinery

Covered in detail on its own page, but the idea is the same: a Proxy wraps an object, traps every fundamental operation, and Reflect supplies the default for each one. That combination is the most powerful meta-programming construct in modern JS.

JS
const audited = new Proxy({ value: 0 }, {
  set(target, prop, value, receiver) {
    console.log("audit:", prop, "->", value);
    return Reflect.set(target, prop, value, receiver);
  },
});

audited.value = 10;
Decorators — a preview

Decorators are a special syntax (@name) for wrapping classes, methods and fields at definition time. The Stage 3 proposal is stable in TypeScript and shipping in modern engines.

Illustrative — decorator that logs calls

JS
function log(value, context) {
  if (context.kind !== "method") return value;
  return function (...args) {
    console.log("call", context.name, args);
    return value.apply(this, args);
  };
}

class Calc {
  @log
  add(a, b) { return a + b; }
}

new Calc().add(2, 3);   // logs: call add [ 2, 3 ]

Frameworks like Angular and NestJS use decorators heavily for dependency injection, routing and validation. The underlying tool is "take a class/method, wrap it, return the wrapped version" — meta-programming with friendly syntax.

What can go wrong
  • Surprising behaviour. Objects that magically validate, observe or convert are easier to break than plain ones. Document hooks loudly.

  • Performance. Proxies and accessors add per-operation work. Avoid them on hot inner data.

  • Debugger friction. Stepping through a wrapped getter or a proxy trap can be confusing — turn on framework-internal stack frame hiding if your tools support it.

  • TypeScript types. Some meta-programming is hard or impossible to type accurately.

Where it really pays off
  • Reactive state systems (Vue, MobX, Solid) — Proxy + accessors track dependencies automatically.

  • ORMs and validators — define a schema, generate getters/setters that coerce and check.

  • Logging, tracing and audit layers — wrap a service so every call is recorded without touching its body.

  • Library DSLs — a query object that lets you write q.users.where(...) ergonomically.

The line
Use meta-programming where it removes a clear class of boilerplate, not because it is clever. Code that looks normal but does something surprising is hard for the next reader — including future you.
Tip
If you ever wondered "how does Vue know my variable changed?", the answer is a Proxy plus a small dependency tracker. Building a toy version of that is one of the best ways to internalise these APIs.