JS Metaprogramming Advanced Architecture Guide

Master JavaScript metaprogramming for dynamic, self-aware code architectures. Covers Proxy meta-object protocol, Reflect API integration, Symbol-based protocols, property descriptors, dynamic class creation, aspect-oriented programming, DSL construction, and compile-time vs runtime metaprogramming.

JavaScriptadvanced
19 min read

Metaprogramming is code that manipulates code. JavaScript provides three pillars for metaprogramming: Proxy (intercept operations), Reflect (perform operations programmatically), and Symbol (define protocols). Together they enable dynamic dispatch, aspect-oriented programming, domain-specific languages, and self-modifying architectures.

For Proxy and Reflect fundamentals, see Using Reflect and Proxy Together in JavaScript.

The Meta-Object Protocol

JavaScript's Proxy API gives you 13 traps that correspond to the language's fundamental object operations: property access, assignment, deletion, enumeration, and more. By hooking into these, you can intercept literally everything that happens to an object. The first example builds a transparent logging proxy that forwards all operations through Reflect while recording them. The second creates a virtual object where properties do not actually exist but are computed on the fly.

javascriptjavascript
// JavaScript's MOP exposes 13 fundamental operations via Proxy traps
// These intercept ALL object interactions at the language level
 
// TRANSPARENT PROXY: intercepts everything, logs operations
function createMetaProxy(target, label = "object") {
  const traps = [
    "get", "set", "has", "deleteProperty", "ownKeys",
    "getOwnPropertyDescriptor", "defineProperty",
    "getPrototypeOf", "setPrototypeOf", "isExtensible",
    "preventExtensions", "apply", "construct"
  ];
 
  const handler = {};
 
  for (const trap of traps) {
    handler[trap] = function (...args) {
      console.log(`[${label}] ${trap}(${formatArgs(args.slice(1))})`);
      return Reflect[trap](...args);
    };
  }
 
  return new Proxy(target, handler);
}
 
function formatArgs(args) {
  return args.map(a => {
    if (typeof a === "symbol") return a.toString();
    if (typeof a === "object" && a !== null) return JSON.stringify(a);
    return String(a);
  }).join(", ");
}
 
const obj = createMetaProxy({ x: 1, y: 2 }, "point");
 
obj.x;          // [point] get(x)
obj.z = 3;      // [point] set(z, 3)
"x" in obj;     // [point] has(x)
delete obj.z;   // [point] deleteProperty(z)
Object.keys(obj); // [point] ownKeys()
 
// VIRTUAL OBJECTS: no real properties, everything is computed
function createVirtualObject(computeFn) {
  return new Proxy(Object.create(null), {
    get(target, prop) {
      if (prop === Symbol.toPrimitive) return () => "[VirtualObject]";
      if (typeof prop === "symbol") return undefined;
      return computeFn(prop);
    },
 
    has(target, prop) {
      return true; // Every property "exists"
    },
 
    ownKeys() {
      return []; // No enumerable properties
    },
 
    getOwnPropertyDescriptor(target, prop) {
      return {
        value: computeFn(prop),
        writable: false,
        enumerable: true,
        configurable: true
      };
    }
  });
}
 
// Math constants virtual object
const constants = createVirtualObject(name => {
  const values = { PI: Math.PI, E: Math.E, TAU: Math.PI * 2, PHI: (1 + Math.sqrt(5)) / 2 };
  return values[name] || undefined;
});
 
console.log(constants.PI);  // 3.141592653589793
console.log(constants.TAU); // 6.283185307179586
console.log(constants.PHI); // 1.618033988749895

Symbol-Based Protocols

Well-known Symbols are JavaScript's way of letting you plug into language-level behavior. By implementing Symbol.toPrimitive, your object controls how it converts to strings and numbers. Symbol.iterator makes it work in for...of loops. Symbol.hasInstance customizes instanceof checks. The Money class below implements all of these, and further down you will see how to define your own application-level protocols using custom Symbols for serialization.

javascriptjavascript
// Well-known Symbols define object behavior in the language
 
// Symbol.iterator: make objects iterable
// Symbol.toPrimitive: control type conversion
// Symbol.hasInstance: customize instanceof
// Symbol.species: control constructor for derived objects
// Symbol.toStringTag: customize Object.prototype.toString
 
class Money {
  #amount;
  #currency;
 
  constructor(amount, currency = "USD") {
    this.#amount = amount;
    this.#currency = currency;
  }
 
  // Control type coercion
  [Symbol.toPrimitive](hint) {
    switch (hint) {
      case "number": return this.#amount;
      case "string": return `${this.#currency} ${this.#amount.toFixed(2)}`;
      default: return this.#amount;
    }
  }
 
  // Customize Object.prototype.toString
  get [Symbol.toStringTag]() {
    return "Money";
  }
 
  // Customize instanceof
  static [Symbol.hasInstance](instance) {
    return instance &&
      typeof instance[Symbol.toPrimitive] === "function" &&
      typeof instance.convert === "function";
  }
 
  // Make iterable (yields [currency, amount])
  *[Symbol.iterator]() {
    yield this.#currency;
    yield this.#amount;
  }
 
  convert(targetCurrency, rate) {
    return new Money(this.#amount * rate, targetCurrency);
  }
}
 
const price = new Money(29.99, "USD");
 
console.log(+price);            // 29.99 (number hint)
console.log(`${price}`);        // "USD 29.99" (string hint)
console.log(price + 10);        // 39.99 (default hint -> number)
console.log(Object.prototype.toString.call(price)); // "[object Money]"
 
const [currency, amount] = price;
console.log(currency, amount);  // "USD" 29.99
 
// CUSTOM PROTOCOL WITH SYMBOLS
const Serializable = Symbol("Serializable");
const Deserializable = Symbol("Deserializable");
 
class Model {
  [Serializable]() {
    const data = {};
    for (const key of Object.keys(this)) {
      data[key] = this[key];
    }
    return JSON.stringify(data);
  }
 
  static [Deserializable](json) {
    const data = JSON.parse(json);
    const instance = new this();
    Object.assign(instance, data);
    return instance;
  }
}
 
function serialize(obj) {
  if (typeof obj[Serializable] !== "function") {
    throw new TypeError("Object is not serializable");
  }
  return obj[Serializable]();
}
 
function deserialize(Class, json) {
  if (typeof Class[Deserializable] !== "function") {
    throw new TypeError("Class is not deserializable");
  }
  return Class[Deserializable](json);
}

Dynamic Class Construction

Sometimes you need to create classes at runtime from a schema or configuration file instead of writing them by hand. The createClass function below takes a name, property definitions with types and validation rules, and a set of methods, then builds a real JavaScript class with a constructor that validates incoming data. The second half shows how to combine these dynamic classes with mixin composition to add behaviors like timestamps and soft-delete.

javascriptjavascript
// Create classes at runtime based on schemas or configurations
 
function createClass(name, schema) {
  const { properties = {}, methods = {}, validators = {} } = schema;
 
  // Build the class dynamically
  const DynamicClass = class {
    constructor(data = {}) {
      // Validate and assign properties
      for (const [prop, config] of Object.entries(properties)) {
        const value = data[prop] !== undefined ? data[prop] : config.default;
 
        if (config.required && value === undefined) {
          throw new Error(`${name}: ${prop} is required`);
        }
 
        if (value !== undefined && config.type) {
          const actualType = typeof value;
          if (actualType !== config.type) {
            throw new TypeError(
              `${name}.${prop}: expected ${config.type}, got ${actualType}`
            );
          }
        }
 
        if (validators[prop] && value !== undefined) {
          const error = validators[prop](value);
          if (error) throw new Error(`${name}.${prop}: ${error}`);
        }
 
        this[prop] = value;
      }
    }
 
    toJSON() {
      const result = {};
      for (const prop of Object.keys(properties)) {
        if (this[prop] !== undefined) result[prop] = this[prop];
      }
      return result;
    }
 
    get [Symbol.toStringTag]() {
      return name;
    }
  };
 
  // Add methods
  for (const [methodName, fn] of Object.entries(methods)) {
    DynamicClass.prototype[methodName] = fn;
  }
 
  // Set the class name
  Object.defineProperty(DynamicClass, "name", { value: name });
 
  return DynamicClass;
}
 
const UserModel = createClass("User", {
  properties: {
    name: { type: "string", required: true },
    email: { type: "string", required: true },
    age: { type: "number", default: 0 },
    role: { type: "string", default: "user" }
  },
  validators: {
    email: (v) => v.includes("@") ? null : "invalid email format",
    age: (v) => v >= 0 ? null : "age must be non-negative"
  },
  methods: {
    greet() { return `Hello, I'm ${this.name}`; },
    isAdmin() { return this.role === "admin"; }
  }
});
 
const user = new UserModel({ name: "Alice", email: "alice@example.com", age: 30 });
console.log(user.greet());    // "Hello, I'm Alice"
console.log(user.toJSON());   // { name: "Alice", email: "alice@example.com", age: 30, role: "user" }
console.log(UserModel.name);  // "User"
 
// MIXIN COMPOSITION
function mixin(Base, ...mixins) {
  class Mixed extends Base {}
 
  for (const mix of mixins) {
    // Copy instance methods
    for (const key of Object.getOwnPropertyNames(mix.prototype)) {
      if (key === "constructor") continue;
      Object.defineProperty(
        Mixed.prototype,
        key,
        Object.getOwnPropertyDescriptor(mix.prototype, key)
      );
    }
 
    // Copy static methods
    for (const key of Object.getOwnPropertyNames(mix)) {
      if (["prototype", "name", "length"].includes(key)) continue;
      Object.defineProperty(
        Mixed,
        key,
        Object.getOwnPropertyDescriptor(mix, key)
      );
    }
  }
 
  return Mixed;
}
 
class Timestamped {
  initTimestamps() {
    this.createdAt = new Date();
    this.updatedAt = new Date();
  }
 
  touch() {
    this.updatedAt = new Date();
  }
}
 
class SoftDeletable {
  softDelete() {
    this.deletedAt = new Date();
    this.isDeleted = true;
  }
 
  restore() {
    this.deletedAt = null;
    this.isDeleted = false;
  }
}
 
const EnhancedUser = mixin(UserModel, Timestamped, SoftDeletable);
const enhanced = new EnhancedUser({ name: "Bob", email: "bob@example.com" });
enhanced.initTimestamps();
enhanced.softDelete();
console.log(enhanced.isDeleted); // true
enhanced.restore();
console.log(enhanced.isDeleted); // false

Aspect-Oriented Programming

AOP separates cross-cutting concerns like logging, timing, and error handling from your business logic. Instead of scattering console.log calls throughout your code, you define aspects that hook into method calls at specific points: before execution, after it returns, or after it throws. The Aspect.wrap method uses a Proxy to intercept all method calls on an object and run your aspects without touching the original class at all.

javascriptjavascript
// AOP: add cross-cutting concerns (logging, caching, auth) without modifying code
 
class Aspect {
  static before(target, methodName, advice) {
    const original = target.prototype[methodName];
 
    target.prototype[methodName] = function (...args) {
      advice.call(this, methodName, args);
      return original.apply(this, args);
    };
  }
 
  static after(target, methodName, advice) {
    const original = target.prototype[methodName];
 
    target.prototype[methodName] = function (...args) {
      const result = original.apply(this, args);
      advice.call(this, methodName, result, args);
      return result;
    };
  }
 
  static around(target, methodName, advice) {
    const original = target.prototype[methodName];
 
    target.prototype[methodName] = function (...args) {
      return advice.call(this, original.bind(this), args);
    };
  }
 
  // Apply aspects via Proxy (non-invasive)
  static wrap(instance, aspects) {
    return new Proxy(instance, {
      get(target, prop, receiver) {
        const value = Reflect.get(target, prop, receiver);
 
        if (typeof value !== "function") return value;
 
        return function (...args) {
          const ctx = { target, method: prop, args };
 
          // Before advice
          for (const aspect of aspects) {
            if (aspect.before) aspect.before(ctx);
          }
 
          let result;
          try {
            result = value.apply(target, args);
            ctx.result = result;
 
            // After advice (on success)
            for (const aspect of aspects) {
              if (aspect.afterReturning) aspect.afterReturning(ctx);
            }
          } catch (err) {
            ctx.error = err;
 
            // After throwing advice
            for (const aspect of aspects) {
              if (aspect.afterThrowing) aspect.afterThrowing(ctx);
            }
 
            throw err;
          } finally {
            // After (always) advice
            for (const aspect of aspects) {
              if (aspect.after) aspect.after(ctx);
            }
          }
 
          return result;
        };
      }
    });
  }
}
 
// Usage
class OrderService {
  placeOrder(item, quantity) {
    return { id: Date.now(), item, quantity, total: quantity * 9.99 };
  }
 
  cancelOrder(orderId) {
    return { id: orderId, status: "cancelled" };
  }
}
 
const loggingAspect = {
  before(ctx) {
    console.log(`Calling ${ctx.method}(${JSON.stringify(ctx.args)})`);
  },
  afterReturning(ctx) {
    console.log(`${ctx.method} returned: ${JSON.stringify(ctx.result)}`);
  },
  afterThrowing(ctx) {
    console.error(`${ctx.method} threw: ${ctx.error.message}`);
  }
};
 
const timingAspect = {
  before(ctx) {
    ctx.startTime = performance.now();
  },
  after(ctx) {
    const duration = performance.now() - ctx.startTime;
    console.log(`${ctx.method} took ${duration.toFixed(2)}ms`);
  }
};
 
const service = Aspect.wrap(new OrderService(), [loggingAspect, timingAspect]);
service.placeOrder("Widget", 3);
// Calling placeOrder(["Widget",3])
// placeOrder returned: {"id":...,"item":"Widget","quantity":3,"total":29.97}
// placeOrder took 0.12ms

Domain-Specific Languages

Proxy's get trap makes it possible to build APIs that read almost like plain English. Every property access on the proxy returns either a function (for methods that take arguments) or the builder itself (for chainable keywords). The query builder below lets you write .from("users").select("id", "name").where("age", ">", 18) and compiles it to SQL. Below that, a validation DSL uses the same technique to let you chain type checks and constraints.

javascriptjavascript
// Build DSLs using Proxy for fluent, readable APIs
 
function createQueryBuilder() {
  const query = {
    table: null,
    conditions: [],
    fields: ["*"],
    orderBy: null,
    limitVal: null,
    joins: []
  };
 
  const builder = new Proxy({}, {
    get(target, prop) {
      switch (prop) {
        case "from":
          return (table) => { query.table = table; return builder; };
        case "select":
          return (...fields) => { query.fields = fields; return builder; };
        case "where":
          return (field, op, value) => {
            query.conditions.push({ field, op, value });
            return builder;
          };
        case "and":
          return builder; // Syntactic sugar, returns same builder
        case "orderBy":
          return (field, dir = "ASC") => {
            query.orderBy = { field, dir };
            return builder;
          };
        case "limit":
          return (n) => { query.limitVal = n; return builder; };
        case "join":
          return (table, on) => {
            query.joins.push({ table, on });
            return builder;
          };
        case "build":
          return () => buildSQL(query);
        case "toJSON":
          return () => ({ ...query });
        default:
          return undefined;
      }
    }
  });
 
  return builder;
}
 
function buildSQL(q) {
  let sql = `SELECT ${q.fields.join(", ")} FROM ${q.table}`;
 
  for (const j of q.joins) {
    sql += ` JOIN ${j.table} ON ${j.on}`;
  }
 
  if (q.conditions.length > 0) {
    const where = q.conditions.map(c =>
      `${c.field} ${c.op} '${c.value}'`
    ).join(" AND ");
    sql += ` WHERE ${where}`;
  }
 
  if (q.orderBy) {
    sql += ` ORDER BY ${q.orderBy.field} ${q.orderBy.dir}`;
  }
 
  if (q.limitVal) {
    sql += ` LIMIT ${q.limitVal}`;
  }
 
  return sql;
}
 
const sql = createQueryBuilder()
  .from("users")
  .select("id", "name", "email")
  .where("age", ">", 18)
  .and.where("status", "=", "active")
  .join("orders", "orders.user_id = users.id")
  .orderBy("name")
  .limit(10)
  .build();
 
console.log(sql);
// SELECT id, name, email FROM users JOIN orders ON orders.user_id = users.id
// WHERE age > '18' AND status = 'active' ORDER BY name ASC LIMIT 10
 
// VALIDATION DSL
function schema() {
  const rules = [];
 
  const builder = new Proxy({}, {
    get(_, prop) {
      switch (prop) {
        case "string":
          rules.push({ type: "string" });
          return builder;
        case "number":
          rules.push({ type: "number" });
          return builder;
        case "required":
          rules.push({ check: v => v != null, msg: "is required" });
          return builder;
        case "min":
          return (n) => { rules.push({ check: v => v >= n, msg: `must be >= ${n}` }); return builder; };
        case "max":
          return (n) => { rules.push({ check: v => v <= n, msg: `must be <= ${n}` }); return builder; };
        case "matches":
          return (re) => { rules.push({ check: v => re.test(v), msg: `must match ${re}` }); return builder; };
        case "validate":
          return (value) => {
            const errors = [];
            for (const rule of rules) {
              if (rule.type && typeof value !== rule.type) {
                errors.push(`expected ${rule.type}`);
              }
              if (rule.check && !rule.check(value)) {
                errors.push(rule.msg);
              }
            }
            return errors.length > 0 ? errors : null;
          };
        default:
          return builder;
      }
    }
  });
 
  return builder;
}
 
const ageValidator = schema().number.required.min(0).max(150);
console.log(ageValidator.validate(25));   // null (valid)
console.log(ageValidator.validate(-5));   // ["must be >= 0"]
console.log(ageValidator.validate(200));  // ["must be <= 150"]
TechniqueMechanismUse CaseTrade-off
Proxy trapsIntercept operationsVirtual objects, logging, access controlRuntime overhead per operation
Reflect APIProgrammatic operationsForward operations, maintain invariantsSlight verbosity vs direct access
Well-known SymbolsProtocol hooksIterables, type coercion, instanceofMust understand protocol contracts
Dynamic classesRuntime constructionSchema-driven models, ORMsHarder to type-check statically
AOP via ProxyNon-invasive wrappingLogging, caching, auth, timingAdded indirection layer
DSL buildersProxy get trapsQuery builders, validation, routingLimited IDE autocomplete
Rune AI

Rune AI

Key Insights

  • The meta-object protocol (13 Proxy traps + Reflect) intercepts ALL fundamental operations on objects: This enables virtual objects, transparent logging, access control, and invariant enforcement
  • Well-known Symbols define how objects participate in language protocols like iteration, type coercion, and instanceof: Custom Symbol-based protocols extend this pattern for application-level contracts
  • Dynamic class construction from schemas enables runtime model generation for ORMs, form builders, and configuration-driven architectures: Combine with mixin composition for reusable cross-cutting behaviors
  • Aspect-oriented programming via Proxy wrapping adds logging, caching, timing, and authorization without modifying original code: The Proxy-based approach is non-invasive and reversible
  • DSL builders using Proxy get traps create fluent, readable APIs for query construction, validation rules, and configuration: Each property access returns the builder for method chaining
RunePowered by Rune AI

Frequently Asked Questions

Does metaprogramming hurt performance?

Proxy traps add overhead per intercepted operation. V8 cannot inline or optimize proxy accesses the same way it optimizes direct property access. For hot code paths (tight loops, frequently accessed objects), this overhead is measurable. However, for configuration objects, middleware, DSL builders, and initialization-time operations, the overhead is negligible. Profile before optimizing. Use regular objects in hot paths and proxies where flexibility matters. Engines continue improving proxy performance, but they will always be slower than direct access due to the trap dispatch mechanism.

How do I combine multiple metaprogramming techniques safely?

Layer techniques deliberately: use Symbols for protocol definition, Proxy for runtime interception, Reflect for forwarding, and property descriptors for access control. Avoid deeply nested proxies (proxy of a proxy of a proxy) as they multiply overhead and make debugging difficult. Use WeakMap to associate metadata with proxy targets rather than modifying targets directly. Test metaprogramming code thoroughly because bugs often manifest as subtle behavioral differences rather than clear errors.

Can TypeScript understand metaprogrammed code?

TypeScript has limited support for metaprogramming patterns. Proxy getters that return dynamic properties cannot be typed automatically. Dynamic class creation loses type information. To work with TypeScript: define interfaces that describe the expected shape of metaprogrammed objects, use type assertions where necessary, and consider generating TypeScript declaration files for dynamic APIs. Template literal types and conditional types can model some metaprogramming patterns statically, but complex runtime metaprogramming often requires type casting.

When is metaprogramming the wrong choice?

Metaprogramming is wrong when simpler tools solve the problem. If you need validation, use a schema library. If you need logging, use a decorator or middleware function. Metaprogramming adds cognitive complexity, makes debugging harder (stack traces show proxy internals), reduces IDE support (autocomplete, refactoring), and is harder to test. Use it for framework-level concerns (ORM object mapping, reactive systems, sandboxed execution) where the complexity is justified by the API simplification it provides to consumers.

Conclusion

JavaScript metaprogramming through Proxy, Reflect, and Symbols enables dynamic object behavior, aspect-oriented design, and fluent DSLs. These tools are most valuable at framework boundaries where they simplify consumer-facing APIs. For self-modifying code patterns that extend these concepts, see Writing Self-Modifying Code in JS Architecture. For the Proxy-Reflect integration patterns underlying these techniques, revisit Using Reflect and Proxy Together in JavaScript.