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

QuestionCall
every own key, strings and symbolsReflect.ownKeys(obj)
every own descriptor at onceObject.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 true

Copying 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());   // -> undefined

Direct 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.

ToolSees local scopeBlocked by a strict CSPLegitimate use
direct eval(str)yes, and can modify ityesa REPL or debugger console
indirect (0, eval)(str)no, global onlyyesalmost never
new Function(args, body)no, global onlyyestemplate and query compilers, hot paths
JSON.parse(str)not applicablenoparsing data, always prefer this
import(url)nocontrolled by CSP directivesloading 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-Policy without unsafe-eval disables 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 targetcontext.kindvalue it receivesWhat returning something does
class'class'the class itselfreplaces the class
method'method'the functionreplaces the method
getter or setter'getter' / 'setter'the accessor functionreplaces that half
field'field'undefinedan initializer (initial) => value
accessor field'accessor'{ get, set }{ get, set, init }, any subset
  1. Decorator expressions are evaluated top to bottom, in source order.
  2. They are then applied bottom to top, so the decorator nearest the declaration wraps first.
  3. All element decorators run before any class decorator.
  4. Functions registered with addInitializer run when an instance is constructed (or at definition time for static elements).
  5. The context.access object gives a get and set pair that work even for #private members.
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-enumerable

The 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) and new Function(str)?
eval can read and write the local scope where it appears, new Function compiles in global scope — Direct eval runs inside the calling scope and can even add or reassign locals, which is why it defeats optimisation. new Function compiles its body as if written at the top level, so it sees only globals. Both are blocked by a CSP without unsafe-eval, and neither is meaningfully cached.
What does { ...obj } lose that Object.getOwnPropertyDescriptors preserves?
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.addInitializer is for, which is how a @bound decorator binds the method to each new object.
What does this log?
1 3 — Object.keys reports enumerable string keys only, so it sees just a and gives 1. Reflect.ownKeys reports every own key regardless of enumerability, including symbols, so it sees a, b and the symbol and gives 3. When you write anything that copies or inspects objects generically, Reflect.ownKeys plus descriptors is the honest pair.

Common mistakes

  • Passing a user-controlled string as a dynamic property key, which opens prototype pollution.
  • Using eval to parse JSON. JSON.parse is safer and faster.
  • Expecting new Function to 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: false by 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.ownKeys plus Object.getOwnPropertyDescriptors is the honest way to inspect or copy an object.
  • Spread flattens getters, drops non-enumerables and resets the prototype.
  • eval sees local scope, new Function sees 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.