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.definePropertyand 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
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— drivesfor..of, spread, destructuring.Symbol.asyncIterator— same idea, forfor await..of.Symbol.toPrimitive— choose how your object converts to number/string.Symbol.hasInstance— customiseinstanceof.Symbol.toStringTag— what shows up inObject.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.
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.
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
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
queryobject that lets you writeq.users.where(...)ergonomically.