Metaprogramming Patterns in JavaScript
Metaprogramming patterns enable writing code that manipulates other code. This article covers decorators, mixins, property interception, and advanced patterns.
Introduction
Metaprogramming patterns allow:
- Code modification and enhancement
- Dynamic behavior injection
- Property interception
- Method wrapping
- Advanced composition
Understanding these patterns helps you:
- Build flexible frameworks
- Implement cross-cutting concerns
- Create reusable abstractions
- Enhance existing code
- Write maintainable systems
Decorator Pattern
Function Decorators
// โ
Good: Basic function decorator
function logger(fn) {
return function(...args) {
console.log(`Calling ${fn.name} with:`, args);
const result = fn(...args);
console.log(`Result:`, result);
return result;
};
}
function add(a, b) {
return a + b;
}
const loggedAdd = logger(add);
console.log(loggedAdd(2, 3));
// Calling add with: [2, 3]
// Result: 5
// 5
Method Decorators
// โ
Good: Decorator for class methods
function timing(target, propertyKey, descriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args) {
const start = performance.now();
const result = originalMethod.apply(this, args);
const end = performance.now();
console.log(`${propertyKey} took ${end - start}ms`);
return result;
};
return descriptor;
}
class Calculator {
@timing
slowCalculation(n) {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += i;
}
return sum;
}
}
// Note: Decorators require experimental support
// Alternative without decorators:
class Calculator {
slowCalculation(n) {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += i;
}
return sum;
}
}
Calculator.prototype.slowCalculation = timing(
Calculator.prototype,
'slowCalculation',
Object.getOwnPropertyDescriptor(Calculator.prototype, 'slowCalculation')
).value;
Practical Decorators
// โ
Good: Memoization decorator
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log('Cache hit for:', key);
return cache.get(key);
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const memoizedFib = memoize(fibonacci);
console.log(memoizedFib(10)); // Computed
console.log(memoizedFib(10)); // Cache hit
// โ
Good: Retry decorator
function retry(fn, maxAttempts = 3) {
return async function(...args) {
for (let i = 0; i < maxAttempts; i++) {
try {
return await fn(...args);
} catch (error) {
if (i === maxAttempts - 1) throw error;
console.log(`Attempt ${i + 1} failed, retrying...`);
}
}
};
}
async function fetchData(url) {
const response = await fetch(url);
if (!response.ok) throw new Error('Network error');
return response.json();
}
const robustFetch = retry(fetchData, 3);
Mixin Pattern
Object Mixins
// โ
Good: Mixin pattern for code reuse
const canEat = {
eat() {
console.log(`${this.name} is eating`);
}
};
const canWalk = {
walk() {
console.log(`${this.name} is walking`);
}
};
const canSwim = {
swim() {
console.log(`${this.name} is swimming`);
}
};
class Animal {
constructor(name) {
this.name = name;
}
}
// Apply mixins
Object.assign(Animal.prototype, canEat, canWalk);
class Duck extends Animal {}
Object.assign(Duck.prototype, canSwim);
const duck = new Duck('Donald');
duck.eat(); // Donald is eating
duck.walk(); // Donald is walking
duck.swim(); // Donald is swimming
Mixin Factory
// โ
Good: Mixin factory for flexible composition
function createTimestampMixin() {
return {
getCreatedAt() {
return this.createdAt;
},
setCreatedAt(date) {
this.createdAt = date;
}
};
}
function createValidationMixin() {
return {
validate() {
if (!this.name) {
throw new Error('Name is required');
}
return true;
}
};
}
class User {
constructor(name) {
this.name = name;
this.createdAt = new Date();
}
}
// Apply mixins
Object.assign(User.prototype, createTimestampMixin());
Object.assign(User.prototype, createValidationMixin());
const user = new User('John');
user.validate(); // true
console.log(user.getCreatedAt()); // Current date
Trait Pattern
// โ
Good: Trait pattern for composition
class Trait {
static compose(...traits) {
return class Composed {
constructor(...args) {
traits.forEach(trait => {
Object.assign(this, new trait(...args));
});
}
};
}
}
class Swimmer {
swim() {
return 'swimming';
}
}
class Flyer {
fly() {
return 'flying';
}
}
class Runner {
run() {
return 'running';
}
}
class Duck extends Trait.compose(Swimmer, Flyer, Runner) {}
const duck = new Duck();
console.log(duck.swim()); // swimming
console.log(duck.fly()); // flying
console.log(duck.run()); // running
Property Interception
Getter/Setter Interception
// โ
Good: Intercept property access
class User {
constructor(name, email) {
this._name = name;
this._email = email;
}
get name() {
console.log('Getting name');
return this._name;
}
set name(value) {
console.log('Setting name to:', value);
if (!value) throw new Error('Name cannot be empty');
this._name = value;
}
get email() {
console.log('Getting email');
return this._email;
}
set email(value) {
console.log('Setting email to:', value);
if (!value.includes('@')) throw new Error('Invalid email');
this._email = value;
}
}
const user = new User('John', '[email protected]');
console.log(user.name); // Getting name, 'John'
user.name = 'Jane'; // Setting name to: Jane
Proxy-based Interception
// โ
Good: Proxy for property interception
const user = {
name: 'John',
age: 30
};
const handler = {
get(target, property) {
console.log(`Getting ${property}`);
return target[property];
},
set(target, property, value) {
console.log(`Setting ${property} to ${value}`);
if (property === 'age' && value < 0) {
throw new Error('Age cannot be negative');
}
target[property] = value;
return true;
}
};
const proxiedUser = new Proxy(user, handler);
console.log(proxiedUser.name); // Getting name
proxiedUser.age = 31; // Setting age to 31
Method Wrapping
Before/After Wrapping
// โ
Good: Wrap methods with before/after logic
function wrapMethod(obj, methodName, before, after) {
const original = obj[methodName];
obj[methodName] = function(...args) {
if (before) before.call(this, methodName, args);
const result = original.apply(this, args);
if (after) after.call(this, methodName, result);
return result;
};
}
class Logger {
log(message) {
console.log(`LOG: ${message}`);
}
}
const logger = new Logger();
wrapMethod(
logger,
'log',
(method, args) => console.log(`[BEFORE] Calling ${method}`),
(method, result) => console.log(`[AFTER] ${method} completed`)
);
logger.log('Hello');
// [BEFORE] Calling log
// LOG: Hello
// [AFTER] log completed
Aspect-Oriented Programming
// โ
Good: AOP pattern for cross-cutting concerns
class AOP {
static before(obj, method, fn) {
const original = obj[method];
obj[method] = function(...args) {
fn.apply(this, args);
return original.apply(this, args);
};
}
static after(obj, method, fn) {
const original = obj[method];
obj[method] = function(...args) {
const result = original.apply(this, args);
fn.call(this, result);
return result;
};
}
static around(obj, method, fn) {
const original = obj[method];
obj[method] = function(...args) {
return fn.call(this, original, args);
};
}
}
class UserService {
createUser(name) {
return { id: 1, name };
}
}
const service = new UserService();
// Add logging before
AOP.before(service, 'createUser', function(name) {
console.log(`Creating user: ${name}`);
});
// Add logging after
AOP.after(service, 'createUser', function(result) {
console.log(`User created:`, result);
});
service.createUser('John');
// Creating user: John
// User created: { id: 1, name: 'John' }
Code Generation
Template-based Generation
// โ
Good: Generate code from templates
function generateGetter(propertyName) {
return function() {
return this[`_${propertyName}`];
};
}
function generateSetter(propertyName) {
return function(value) {
this[`_${propertyName}`] = value;
};
}
class User {
constructor(name, email) {
this._name = name;
this._email = email;
}
}
// Generate getters and setters
Object.defineProperty(User.prototype, 'name', {
get: generateGetter('name'),
set: generateSetter('name')
});
Object.defineProperty(User.prototype, 'email', {
get: generateGetter('email'),
set: generateSetter('email')
});
const user = new User('John', '[email protected]');
console.log(user.name); // John
user.name = 'Jane';
console.log(user.name); // Jane
Dynamic Class Generation
// โ
Good: Generate classes dynamically
function createModel(schema) {
class Model {
constructor(data = {}) {
Object.assign(this, data);
}
validate() {
for (const [key, type] of Object.entries(schema)) {
if (!(key in this)) {
throw new Error(`Missing required field: ${key}`);
}
if (typeof this[key] !== type) {
throw new Error(
`Field ${key} must be ${type}, got ${typeof this[key]}`
);
}
}
return true;
}
toJSON() {
const result = {};
for (const key of Object.keys(schema)) {
result[key] = this[key];
}
return result;
}
}
return Model;
}
// Usage
const UserModel = createModel({
id: 'number',
name: 'string',
email: 'string'
});
const user = new UserModel({
id: 1,
name: 'John',
email: '[email protected]'
});
user.validate(); // true
console.log(user.toJSON());
// { id: 1, name: 'John', email: '[email protected]' }
Practical Metaprogramming Patterns
Observable Pattern
// โ
Good: Observable pattern with metaprogramming
class Observable {
constructor(data = {}) {
this._data = data;
this._observers = [];
return new Proxy(this, {
get: (target, property) => {
if (property.startsWith('_')) {
return target[property];
}
return target._data[property];
},
set: (target, property, value) => {
if (property.startsWith('_')) {
target[property] = value;
return true;
}
const oldValue = target._data[property];
if (oldValue !== value) {
target._data[property] = value;
target._notifyObservers(property, oldValue, value);
}
return true;
}
});
}
subscribe(observer) {
this._observers.push(observer);
}
_notifyObservers(property, oldValue, newValue) {
this._observers.forEach(observer => {
observer({ property, oldValue, newValue });
});
}
}
// Usage
const user = new Observable({ name: 'John', age: 30 });
user.subscribe(({ property, oldValue, newValue }) => {
console.log(`${property} changed from ${oldValue} to ${newValue}`);
});
user.name = 'Jane'; // name changed from John to Jane
user.age = 31; // age changed from 30 to 31
Validation Decorator
// โ
Good: Validation using metaprogramming
class ValidatedModel {
constructor(data, rules) {
this.data = data;
this.rules = rules;
return new Proxy(this, {
set: (target, property, value) => {
if (property === 'data' || property === 'rules') {
target[property] = value;
return true;
}
const rule = target.rules[property];
if (rule && !rule.validate(value)) {
throw new Error(rule.message);
}
target.data[property] = value;
return true;
},
get: (target, property) => {
if (property === 'data' || property === 'rules') {
return target[property];
}
return target.data[property];
}
});
}
}
// Usage
const userRules = {
email: {
validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: 'Invalid email format'
},
age: {
validate: (value) => value >= 0 && value <= 150,
message: 'Age must be between 0 and 150'
}
};
const user = new ValidatedModel({ email: '', age: 0 }, userRules);
user.email = '[email protected]'; // OK
user.age = 30; // OK
user.email = 'invalid'; // Error: Invalid email format
Best Practices
-
Use decorators for cross-cutting concerns:
// โ Good const logged = logger(myFunction); // โ Bad function myFunction() { console.log('called'); // ... logic } -
Prefer composition over inheritance:
// โ Good Object.assign(obj, mixin1, mixin2); // โ Bad class Child extends Parent1 extends Parent2 { } -
Use Proxy for property interception:
// โ Good const proxy = new Proxy(obj, handler); // โ Bad Object.defineProperty(obj, 'prop', { get() { } });
Common Mistakes
-
Modifying prototypes globally:
// โ Bad - affects all instances Array.prototype.custom = function() { }; // โ Good - use composition const enhanced = Object.assign([], array, mixin); -
Infinite recursion in decorators:
// โ Bad function decorator(fn) { return function() { return decorator(fn)(); // Infinite recursion }; } // โ Good function decorator(fn) { return function(...args) { return fn(...args); }; } -
Not preserving context:
// โ Bad function decorator(fn) { return () => fn(); // Lost context // โ Good function decorator(fn) { return function(...args) { return fn.apply(this, args); }; }
Summary
Metaprogramming patterns enable powerful code manipulation. Key takeaways:
- Decorators enhance functions and methods
- Mixins enable code reuse
- Property interception controls access
- Method wrapping adds cross-cutting concerns
- Code generation creates dynamic behavior
- Proxy provides powerful interception
- Composition is more flexible than inheritance
- Use patterns for maintainability
Related Resources
Next Steps
- Learn about Advanced Object Manipulation
- Explore Design Patterns & Architecture
- Study Proxy Objects
- Practice decorator patterns
- Build reusable mixins
Comments