Metaprogramming
Mental model: Metaprogramming is writing code whose subject is other code: instead of asking an object for a value, you ask it about its own shape, or rewrite how it behaves.
Level: advanced · about 18 minutes
Ordinary code asks an object for a value. Metaprogramming asks an object about itself, or changes the rules it plays by. You have already done three quarters of it: computed keys, Reflect, Proxy and the well-known symbols are all metaprogramming. This lesson fills in the rest and, more usefully, tells you when to stop.
Dynamic property access
const row = { id: 7, name: 'Tea', price: 250 };
const field = 'name';
console.log(row[field]); // -> Tea
console.log(row?.[field]?.length); // -> 3, optional chaining works with a dynamic key
// Build an object with computed keys
const key = 'total';
const summary = { [key]: 250, [`${key}Formatted`]: '2.50' };
console.log(summary); // -> { total: 250, totalFormatted: '2.50' }
// Rename every key without knowing them in advance
const snake = Object.fromEntries(
Object.entries(row).map(([k, v]) => [k.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()), v])
);
console.log(snake); // -> { id: 7, name: 'Tea', price: 250 }
// Pick and omit, written once, used everywhere
const pick = (obj, keys) => Object.fromEntries(keys.filter((k) => k in obj).map((k) => [k, obj[k]]));
console.log(pick(row, ['id', 'price', 'missing'])); // -> { id: 7, price: 250 }The bracket is the whole feature. Everything else is a pattern built on it.
Introspection: asking an object what it is
| Question | Call |
|---|---|
| every own key, strings and symbols | Reflect.ownKeys(obj) |
| every own descriptor at once | Object.getOwnPropertyDescriptors(obj) |
| is this key its own, not inherited? | Object.hasOwn(obj, key) |
| what is the next link in the chain? | Object.getPrototypeOf(obj) |
| is this a getter or a plain value? | 'get' in descriptor |
| what type tag does the engine use? | Object.prototype.toString.call(obj) |
| how many declared parameters? | fn.length (before the first default or rest) |
| is it a class or a plain function? | fn.prototype && !Object.getOwnPropertyDescriptor(fn, 'prototype').writable |
class Animal {
constructor(name) { this.name = name; }
speak() { return '...'; }
}
class Dog extends Animal {
get loud() { return this.name.toUpperCase(); }
speak() { return 'woof'; }
}
function describe(obj) {
const chain = [];
for (let p = Object.getPrototypeOf(obj); p; p = Object.getPrototypeOf(p)) {
chain.push(p.constructor?.name ?? '(anonymous)');
}
const descriptors = Object.getOwnPropertyDescriptors(obj);
return {
tag: Object.prototype.toString.call(obj).slice(8, -1),
ownData: Object.keys(descriptors).filter((k) => 'value' in descriptors[k]),
accessors: Object.keys(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(obj)))
.filter((k) => 'get' in Object.getOwnPropertyDescriptors(Object.getPrototypeOf(obj))[k]),
chain,
};
}
console.log(describe(new Dog('Rex')));
// -> { tag: 'Object', ownData: [ 'name' ], accessors: [ 'loud' ], chain: [ 'Dog', 'Animal', 'Object' ] }A small object inspector. This pattern is the core of every dev-tools formatter.
const source = {
plain: 1,
get computed() { return this.plain * 2; },
};
Object.defineProperty(source, 'hidden', { value: 'x', enumerable: false });
const bySpread = { ...source };
const byDescriptors = Object.create(
Object.getPrototypeOf(source),
Object.getOwnPropertyDescriptors(source)
);
console.log(Object.getOwnPropertyDescriptor(bySpread, 'computed').value); // -> 2, the getter was flattened
console.log(typeof Object.getOwnPropertyDescriptor(byDescriptors, 'computed').get); // -> function, still a getter
console.log('hidden' in bySpread, 'hidden' in byDescriptors); // -> false trueCopying an object faithfully needs descriptors, not assignment.
eval, and why the answer is almost always no
function leaky(input) {
let secret = 'local';
// eval('secret = "changed"'); // direct eval would rewrite the local
return secret;
}
console.log(leaky()); // -> local
// new Function compiles in the *global* scope, so locals are invisible.
function compiled() {
const local = 'hidden';
const fn = new Function('return typeof local;');
return fn();
}
console.log(compiled()); // -> undefinedDirect eval can see and edit local scope. That is the problem, not the feature.
const x = 'outer';
function makeReader() {
const x = 'inner';
return new Function('return x;');
}
console.log(makeReader()());new Function never closes over the scope where it was created. Its body is compiled as if it were written at the top level, so it resolves x in the global scope. In a module (where const x is module-scoped, not global) the same code throws ReferenceError: x is not defined. That scope isolation is the one genuine advantage new Function has over direct eval.
| Tool | Sees local scope | Blocked by a strict CSP | Legitimate use |
|---|---|---|---|
direct eval(str) | yes, and can modify it | yes | a REPL or debugger console |
indirect (0, eval)(str) | no, global only | yes | almost never |
new Function(args, body) | no, global only | yes | template and query compilers, hot paths |
JSON.parse(str) | not applicable | no | parsing data, always prefer this |
import(url) | no | controlled by CSP directives | loading real modules at runtime |
- It is a code injection hole the moment any part of the string comes from outside your program.
- A
Content-Security-Policywithoutunsafe-evaldisables it, so your feature dies in production. - The engine cannot optimise the surrounding function as well, because the scope may be rewritten.
- Stack traces, source maps and breakpoints all get worse.
- Bundlers and minifiers cannot see inside the string, so dead code stays and renamed variables break.
Decorators
A decorator is a function you attach to a class or a class element with @, which runs once at class definition time and may replace what it decorated. The Stage 3 proposal is stable, shipping in TypeScript 5 and Babel, and is what "decorators" now means. Older Angular-style decorators with a PropertyDescriptor third argument are the abandoned legacy design.
function logged(value, context) {
// context: { kind, name, static, private, access, addInitializer, metadata }
if (context.kind !== 'method') throw new TypeError('@logged goes on methods');
return function (...args) {
console.log(`calling ${String(context.name)}`);
return value.apply(this, args);
};
}
function bound(value, context) {
context.addInitializer(function () {
this[context.name] = this[context.name].bind(this);
});
}
class Cart {
#items = [];
@logged
add(item) {
this.#items.push(item);
return this;
}
@bound
render() {
return this.#items.length;
}
}Stage 3 semantics. Needs TypeScript 5 or Babel today, no engine runs this natively yet.
| Decorator target | context.kind | value it receives | What returning something does |
|---|---|---|---|
| class | 'class' | the class itself | replaces the class |
| method | 'method' | the function | replaces the method |
| getter or setter | 'getter' / 'setter' | the accessor function | replaces that half |
| field | 'field' | undefined | an initializer (initial) => value |
accessor field | 'accessor' | { get, set } | { get, set, init }, any subset |
- Decorator expressions are evaluated top to bottom, in source order.
- They are then applied bottom to top, so the decorator nearest the declaration wraps first.
- All element decorators run before any class decorator.
- Functions registered with
addInitializerrun when an instance is constructed (or at definition time for static elements). - The
context.accessobject gives agetandsetpair that work even for#privatemembers.
function decorateMethod(Klass, name, decorator) {
const target = Klass.prototype;
const original = Object.getOwnPropertyDescriptor(target, name);
if (!original || typeof original.value !== 'function') {
throw new TypeError(`${name} is not a method of ${Klass.name}`);
}
const replacement = decorator(original.value, { kind: 'method', name, static: false });
Object.defineProperty(target, name, { ...original, value: replacement });
return Klass;
}
class Cart {
constructor() { this.items = []; }
add(item) { this.items.push(item); return this; }
}
const calls = [];
decorateMethod(Cart, 'add', (fn, ctx) => function (...args) {
calls.push(`${ctx.name}(${args.join(', ')})`);
return fn.apply(this, args);
});
const cart = new Cart().add('tea').add('mug');
console.log(cart.items, calls); // -> [ 'tea', 'mug' ] [ 'add(tea)', 'add(mug)' ]
console.log(Object.keys(Cart.prototype)); // -> [] the method stays non-enumerableThe same wrapping, done by hand. This runs everywhere today and is the exercise pattern.
Try it yourself
Instrument every method
function instrument(obj) {
const counts = {};
for (const key of Reflect.ownKeys(obj)) {
const desc = Object.getOwnPropertyDescriptor(obj, key);
if (typeof desc.value !== 'function') continue;
const original = desc.value;
counts[key] = 0;
Object.defineProperty(obj, key, {
...desc,
value(...args) {
counts[key] += 1;
return original.apply(this, args);
},
});
}
return counts;
}
const api = {
base: '/v1',
get(path) { return this.base + path; },
post(path) { return 'POST ' + this.base + path; },
};
const counts = instrument(api);
api.get('/tea');
api.get('/mug');
api.post('/order');
console.log(counts); // -> { get: 2, post: 1 }
console.log(api.get('/x')); // -> /v1/x the wrapper kept `this`
Make it record durations as well as call counts, then add an only option so you can instrument a subset of methods.
Compile a schema, the way libraries do
// This is the one legitimate new Function pattern: a controlled, developer-authored schema.
function compileValidator(schema) {
const lines = ['const errors = [];'];
for (const [key, type] of Object.entries(schema)) {
lines.push(`if (typeof value["${key}"] !== "${type}") errors.push("${key} must be a ${type}");`);
}
lines.push('return errors;');
return new Function('value', lines.join('\n'));
}
const validate = compileValidator({ name: 'string', age: 'number' });
console.log(validate({ name: 'Ada', age: 36 })); // -> []
console.log(validate({ name: 42, age: 'x' })); // -> [ 'name must be a string', 'age must be a number' ]
console.log(validate.toString().split('\n').length + ' lines of generated code');
Add support for a min rule on numbers. Then compare timings against an interpreted validator and explain why libraries accept the compile step.
Exercises
A decorator without decorator syntax
Write decorateMethod(Klass, name, decorator). It reads the descriptor for name on Klass.prototype, calls decorator(originalFunction, { kind: 'method', name, static: false }), and installs the returned function in its place, keeping the original descriptor flags. Return the class. Throw a TypeError if the name is not an own method of the prototype, or if the decorator does not return a function.
An object inspector
Write inspect(value) returning { tag, dataKeys, methodKeys, accessorKeys, symbolCount, chain }. tag is the engine type tag ('Object', 'Array', 'Map'). dataKeys are own string keys holding a non-function value, methodKeys own string keys holding a function, accessorKeys own string keys with a getter or setter, all in Reflect.ownKeys order and including non-enumerable ones. symbolCount counts own symbol keys. chain lists constructor names up the prototype chain, so a Dog extends Animal instance gives ['Dog', 'Animal', 'Object']. Anything that is not an object or function throws a TypeError.
Check yourself
- What is the main difference between
eval(str)andnew Function(str)? evalcan read and write the local scope where it appears,new Functioncompiles in global scope — Directevalruns inside the calling scope and can even add or reassign locals, which is why it defeats optimisation.new Functioncompiles its body as if written at the top level, so it sees only globals. Both are blocked by a CSP withoutunsafe-eval, and neither is meaningfully cached.- What does
{ ...obj }lose thatObject.getOwnPropertyDescriptorspreserves? - getters (which become plain values), non-enumerable properties, and the prototype — Spread reads each enumerable own property and assigns the result, so a getter is invoked once and flattened into a value, non-enumerable properties are skipped entirely, and the new object gets
Object.prototype.Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj))is the faithful copy. Spread does copy enumerable symbol keys, so that option is wrong. - A Stage 3 method decorator returns a function. When does that replacement take effect?
- once, when the class is defined — Decorators run at class definition time, in one pass, and the returned function is installed on the prototype there and then. Nothing extra happens per call or per instance. Per-instance work is what
context.addInitializeris for, which is how a@bounddecorator binds the method to each new object. - What does this log?
1 3—Object.keysreports enumerable string keys only, so it sees justaand gives 1.Reflect.ownKeysreports every own key regardless of enumerability, including symbols, so it seesa,band the symbol and gives 3. When you write anything that copies or inspects objects generically,Reflect.ownKeysplus descriptors is the honest pair.
Common mistakes
- Passing a user-controlled string as a dynamic property key, which opens prototype pollution.
- Using
evalto parse JSON.JSON.parseis safer and faster. - Expecting
new Functionto close over local variables. - Assuming
{ ...obj }is a faithful copy. Getters flatten and non-enumerables disappear. - Reaching for the legacy decorator signature (
target, key, descriptor). Stage 3 passes(value, context). - Shipping decorators and forgetting that no engine runs them natively, so the build step is mandatory.
- Losing
enumerable: falseby assigning a method instead of redefining it with the original descriptor. - Metaprogramming business logic, where the indirection costs more than the duplication it removed.
Takeaways
- Metaprogramming is code about code: dynamic keys, descriptors,
Reflect,Proxy, decorators. Reflect.ownKeysplusObject.getOwnPropertyDescriptorsis the honest way to inspect or copy an object.- Spread flattens getters, drops non-enumerables and resets the prototype.
evalsees local scope,new Functionsees only globals. Both die under a strict CSP.- Stage 3 decorators receive
(value, context), run once at class definition, and can replace what they decorate. - No engine ships decorators natively in 2026, so using them means committing to a compiler.
- The cost of metaprogramming is paid by whoever reads the code next. Use it at boundaries, not everywhere.