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.
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.
// 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.618033988749895Symbol-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.
// 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.
// 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); // falseAspect-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.
// 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.12msDomain-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.
// 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"]| Technique | Mechanism | Use Case | Trade-off |
|---|---|---|---|
| Proxy traps | Intercept operations | Virtual objects, logging, access control | Runtime overhead per operation |
| Reflect API | Programmatic operations | Forward operations, maintain invariants | Slight verbosity vs direct access |
| Well-known Symbols | Protocol hooks | Iterables, type coercion, instanceof | Must understand protocol contracts |
| Dynamic classes | Runtime construction | Schema-driven models, ORMs | Harder to type-check statically |
| AOP via Proxy | Non-invasive wrapping | Logging, caching, auth, timing | Added indirection layer |
| DSL builders | Proxy get traps | Query builders, validation, routing | Limited IDE autocomplete |
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
Frequently Asked Questions
Does metaprogramming hurt performance?
How do I combine multiple metaprogramming techniques safely?
Can TypeScript understand metaprogrammed code?
When is metaprogramming the wrong choice?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.