Skip to main content
โšก Calmops

Metaprogramming Patterns in JavaScript

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

  1. Use decorators for cross-cutting concerns:

    // โœ… Good
    const logged = logger(myFunction);
    
    // โŒ Bad
    function myFunction() {
      console.log('called');
      // ... logic
    }
    
  2. Prefer composition over inheritance:

    // โœ… Good
    Object.assign(obj, mixin1, mixin2);
    
    // โŒ Bad
    class Child extends Parent1 extends Parent2 { }
    
  3. Use Proxy for property interception:

    // โœ… Good
    const proxy = new Proxy(obj, handler);
    
    // โŒ Bad
    Object.defineProperty(obj, 'prop', { get() { } });
    

Common Mistakes

  1. Modifying prototypes globally:

    // โŒ Bad - affects all instances
    Array.prototype.custom = function() { };
    
    // โœ… Good - use composition
    const enhanced = Object.assign([], array, mixin);
    
  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);
      };
    }
    
  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

Comments