Skip to main content

Metaprogramming Patterns in JavaScript

Created: May 8, 2026 Larry Qu 9 min read

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

  1. Use decorators for cross-cutting concerns:
    // ✅ Good
    const logged = logger(myFunction);
    
    // ❌ Bad
    function myFunction() {
      console.log('called');
      // ... logic
    }
    ```javascript
    
  2. Prefer composition over inheritance:
    // ✅ Good
    Object.assign(obj, mixin1, mixin2);
    
    // ❌ Bad
    class Child extends Parent1 extends Parent2 { }
    ```javascript
    
  3. Use Proxy for property interception:
    // ✅ Good
    const proxy = new Proxy(obj, handler);
    
    // ❌ Bad
    Object.defineProperty(obj, 'prop', { get() { } });
    ```javascript
    

Common Mistakes

  1. Modifying prototypes globally:
    // ❌ Bad - affects all instances
    Array.prototype.custom = function() { };
    
    // ✅ Good - use composition
    const enhanced = Object.assign([], array, mixin);
    ```javascript
    
  2. 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);
      };
    }
    ```javascript
    
  3. 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

Next Steps

Resources

Comments

Share this article

Scan to read on mobile