JavaScript Decorator Pattern: Complete Guide

A complete guide to the JavaScript decorator pattern. Covers function wrapping, method decoration, class decorators, decorator composition, memoization decorators, retry decorators, and the TC39 decorators proposal for modern JavaScript.

JavaScriptadvanced
16 min read

The decorator pattern wraps existing functionality with additional behavior without modifying the original code. In JavaScript, decorators leverage higher-order functions and closures to transparently enhance objects, methods, and classes at runtime.

For practical logging decorators in production architectures, see Using Decorators for Logging in JS Architecture.

Function Decorators

javascriptjavascript
function withLogging(fn, label) {
  return function (...args) {
    console.log(`[${label}] Called with:`, args);
    const start = performance.now();
 
    try {
      const result = fn.apply(this, args);
 
      if (result instanceof Promise) {
        return result.then((val) => {
          console.log(`[${label}] Resolved in ${(performance.now() - start).toFixed(2)}ms:`, val);
          return val;
        }).catch((err) => {
          console.error(`[${label}] Rejected in ${(performance.now() - start).toFixed(2)}ms:`, err);
          throw err;
        });
      }
 
      console.log(`[${label}] Returned in ${(performance.now() - start).toFixed(2)}ms:`, result);
      return result;
    } catch (error) {
      console.error(`[${label}] Threw in ${(performance.now() - start).toFixed(2)}ms:`, error);
      throw error;
    }
  };
}
 
function withRetry(fn, maxRetries = 3, delayMs = 1000) {
  return async function (...args) {
    let lastError;
 
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        return await fn.apply(this, args);
      } catch (error) {
        lastError = error;
        console.warn(`Attempt ${attempt}/${maxRetries} failed:`, error.message);
 
        if (attempt < maxRetries) {
          const backoff = delayMs * Math.pow(2, attempt - 1);
          await new Promise((r) => setTimeout(r, backoff));
        }
      }
    }
 
    throw lastError;
  };
}
 
function withMemoize(fn, keyFn = (...args) => JSON.stringify(args)) {
  const cache = new Map();
 
  const memoized = function (...args) {
    const key = keyFn(...args);
 
    if (cache.has(key)) {
      return cache.get(key);
    }
 
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
 
  memoized.clearCache = () => cache.clear();
  memoized.cacheSize = () => cache.size;
  return memoized;
}
 
// Usage
function fetchUser(id) {
  return fetch(`/api/users/${id}`).then((r) => r.json());
}
 
const decoratedFetch = withLogging(withRetry(fetchUser, 3, 500), "fetchUser");

Method Decorators

javascriptjavascript
function decorateMethod(obj, methodName, decorator) {
  const original = obj[methodName];
 
  if (typeof original !== "function") {
    throw new Error(`${methodName} is not a method`);
  }
 
  obj[methodName] = decorator(original, methodName);
  return obj;
}
 
function readonly(obj, methodName) {
  const original = obj[methodName];
  Object.defineProperty(obj, methodName, {
    value: original,
    writable: false,
    configurable: false,
  });
  return obj;
}
 
function deprecate(message) {
  return function (fn, name) {
    let warned = false;
    return function (...args) {
      if (!warned) {
        console.warn(`DEPRECATED: ${name}() - ${message}`);
        warned = true;
      }
      return fn.apply(this, args);
    };
  };
}
 
function throttle(limitMs) {
  return function (fn) {
    let lastCall = 0;
    let lastResult;
 
    return function (...args) {
      const now = Date.now();
      if (now - lastCall >= limitMs) {
        lastCall = now;
        lastResult = fn.apply(this, args);
      }
      return lastResult;
    };
  };
}
 
function debounce(delayMs) {
  return function (fn) {
    let timer = null;
 
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => fn.apply(this, args), delayMs);
    };
  };
}
 
// Usage
const api = {
  search(query) {
    console.log(`Searching: ${query}`);
    return [`Result for: ${query}`];
  },
 
  legacyMethod() {
    return "old result";
  },
};
 
decorateMethod(api, "search", throttle(1000));
decorateMethod(api, "legacyMethod", deprecate("Use newMethod() instead"));
 
api.search("javascript"); // Executes
api.search("patterns");   // Throttled, returns last result
api.legacyMethod();        // Logs deprecation warning once

Class Decorator Pattern

javascriptjavascript
function withTimestamps(BaseClass) {
  return class extends BaseClass {
    constructor(...args) {
      super(...args);
      this.createdAt = Date.now();
      this.updatedAt = Date.now();
    }
 
    touch() {
      this.updatedAt = Date.now();
      return this;
    }
  };
}
 
function withSerialization(BaseClass) {
  return class extends BaseClass {
    toJSON() {
      const data = {};
      for (const key of Object.keys(this)) {
        if (typeof this[key] !== "function") {
          data[key] = this[key];
        }
      }
      return data;
    }
 
    static fromJSON(json) {
      const data = typeof json === "string" ? JSON.parse(json) : json;
      const instance = new this();
      Object.assign(instance, data);
      return instance;
    }
  };
}
 
function withValidation(schema) {
  return function (BaseClass) {
    return class extends BaseClass {
      validate() {
        const errors = {};
 
        for (const [field, rules] of Object.entries(schema)) {
          if (rules.required && !this[field]) {
            errors[field] = `${field} is required`;
          }
          if (rules.type && typeof this[field] !== rules.type) {
            errors[field] = `${field} must be ${rules.type}`;
          }
          if (rules.min !== undefined && this[field] < rules.min) {
            errors[field] = `${field} must be >= ${rules.min}`;
          }
        }
 
        return {
          valid: Object.keys(errors).length === 0,
          errors,
        };
      }
    };
  };
}
 
// Compose class decorators
class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
 
  greet() {
    return `Hello, ${this.name}`;
  }
}
 
const EnhancedUser = withSerialization(
  withTimestamps(
    withValidation({
      name: { required: true, type: "string" },
      email: { required: true, type: "string" },
    })(User)
  )
);
 
const user = new EnhancedUser("Alice", "alice@test.com");
console.log(user.createdAt);     // timestamp
console.log(user.validate());    // { valid: true, errors: {} }
console.log(user.toJSON());      // { name, email, createdAt, updatedAt }

Decorator Composition Pipeline

javascriptjavascript
function compose(...decorators) {
  return function (target) {
    return decorators.reduceRight((decorated, decorator) => decorator(decorated), target);
  };
}
 
function pipe(...decorators) {
  return function (target) {
    return decorators.reduce((decorated, decorator) => decorator(decorated), target);
  };
}
 
// For functions
function withTiming(fn) {
  return function (...args) {
    const start = performance.now();
    const result = fn.apply(this, args);
    const duration = performance.now() - start;
    console.log(`${fn.name || "anonymous"}: ${duration.toFixed(2)}ms`);
    return result;
  };
}
 
function withValidation(fn) {
  return function (...args) {
    for (const arg of args) {
      if (arg === null || arg === undefined) {
        throw new Error("Arguments cannot be null or undefined");
      }
    }
    return fn.apply(this, args);
  };
}
 
function withErrorBoundary(fn) {
  return function (...args) {
    try {
      return fn.apply(this, args);
    } catch (error) {
      console.error(`Error in ${fn.name}:`, error.message);
      return null;
    }
  };
}
 
// Compose: right-to-left (validation runs first, then timing, then error boundary)
const enhancedProcess = compose(
  withErrorBoundary,
  withTiming,
  withValidation
)(function processData(data) {
  return data.map((item) => item.toUpperCase());
});
 
// Pipe: left-to-right (same order, more readable)
const pipedProcess = pipe(
  withValidation,
  withTiming,
  withErrorBoundary
)(function processData(data) {
  return data.map((item) => item.toUpperCase());
});
 
console.log(enhancedProcess(["hello", "world"])); // ["HELLO", "WORLD"]
console.log(enhancedProcess(null));                 // null (caught by boundary)

Rate Limiter Decorator

javascriptjavascript
function withRateLimit(fn, { maxCalls, windowMs, onLimitReached }) {
  const calls = [];
 
  return function (...args) {
    const now = Date.now();
 
    // Remove expired entries
    while (calls.length > 0 && now - calls[0] > windowMs) {
      calls.shift();
    }
 
    if (calls.length >= maxCalls) {
      if (onLimitReached) {
        onLimitReached(calls.length, maxCalls);
      }
      throw new Error(
        `Rate limit exceeded: ${maxCalls} calls per ${windowMs}ms`
      );
    }
 
    calls.push(now);
    return fn.apply(this, args);
  };
}
 
function withCircuitBreaker(fn, { threshold = 5, resetTimeout = 30000 } = {}) {
  let failures = 0;
  let state = "CLOSED"; // CLOSED, OPEN, HALF_OPEN
  let lastFailureTime = null;
 
  return async function (...args) {
    if (state === "OPEN") {
      if (Date.now() - lastFailureTime > resetTimeout) {
        state = "HALF_OPEN";
      } else {
        throw new Error("Circuit breaker is OPEN");
      }
    }
 
    try {
      const result = await fn.apply(this, args);
      if (state === "HALF_OPEN") {
        state = "CLOSED";
        failures = 0;
      }
      return result;
    } catch (error) {
      failures++;
      lastFailureTime = Date.now();
 
      if (failures >= threshold) {
        state = "OPEN";
        console.warn(`Circuit breaker OPENED after ${failures} failures`);
      }
 
      throw error;
    }
  };
}
 
// Usage
const apiCall = withRateLimit(
  withCircuitBreaker(
    async (endpoint) => {
      const res = await fetch(endpoint);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
    { threshold: 3, resetTimeout: 10000 }
  ),
  { maxCalls: 10, windowMs: 60000, onLimitReached: (c, m) => console.warn(`Rate: ${c}/${m}`) }
);
Decorator TypeWrapsPurposeExample
Function decoratorFunctionsAdd cross-cutting concernslogging, memoize, retry
Method decoratorObject methodsEnhance class behaviorthrottle, deprecate
Class decoratorClassesExtend with new capabilitiestimestamps, serialization
CompositionMultiple decoratorsChain enhancementspipe, compose
ResilienceAsync functionsFault tolerancecircuit breaker, rate limit
Rune AI

Rune AI

Key Insights

  • Function decorators wrap existing functions with additional behavior: Higher-order functions add logging, timing, retry, and caching transparently without modifying the original code
  • Method decorators enhance individual object methods selectively: Apply throttle, debounce, or deprecation warnings to specific methods without affecting the entire class
  • Class decorators extend classes with new capabilities at the class level: Wrapping classes adds timestamps, serialization, and validation through composition rather than inheritance
  • Compose and pipe utilities chain multiple decorators predictably: Right-to-left (compose) or left-to-right (pipe) composition creates readable decorator pipelines
  • Resilience decorators like circuit breaker and rate limiter protect production services: These patterns prevent cascade failures and enforce usage limits at the function boundary
RunePowered by Rune AI

Frequently Asked Questions

How is the decorator pattern different from inheritance?

Inheritance adds behavior at compile time through class hierarchies. Decorators add behavior at runtime by wrapping existing objects. Decorators can be applied conditionally, composed in any order, and stacked without creating deep class hierarchies. You can add logging to one instance without affecting others of the same class.

Will TC39 decorators replace this pattern?

The TC39 Stage 3 decorators proposal provides syntax support (`@logged`, `@memoize`) for class declarations and methods. However, function decorators (higher-order functions) will remain relevant because TC39 decorators only apply to classes and class members. Function wrapping patterns like `withRetry` and `withRateLimit` will continue using the functional approach.

How do I preserve the original function's name and length?

Use `Object.defineProperty` to copy the name and length: `Object.defineProperty(wrapper, 'name', { value: fn.name })` and `Object.defineProperty(wrapper, 'length', { value: fn.length })`. This ensures debugging tools and stack traces show the original function name rather than "anonymous".

Can decorators cause performance issues?

Each decorator adds a function call to the stack. For hot paths called millions of times, stacking many decorators can be measurable. For I/O-bound operations (API calls, database queries), the overhead is negligible. Profile before optimizing. Memoization decorators actually improve performance by caching expensive computations.

How do I unit test decorated functions?

Test the original function and each decorator independently. For the original, verify core logic. For each decorator, test its wrapping behavior with a simple mock function. Then test the composed result to ensure decorators interact correctly. Export both the original and decorated versions from modules to enable isolated testing.

Conclusion

The decorator pattern adds behavior to functions, methods, and classes without modifying their source code. Function decorators wrap with logging, memoization, retry, and rate limiting. Class decorators add timestamps, serialization, and validation. Composition utilities like pipe and compose chain decorators cleanly. For production logging architectures using decorators, see Using Decorators for Logging in JS Architecture. For the strategy pattern that complements decorator-based approach selection, review JavaScript Strategy Pattern: Complete Guide.