Skip to content
Codeloom
JavaScript

The Decorator Pattern in JavaScript

Learn the Decorator pattern in JavaScript -- wrap functions and classes to add logging, caching, validation, and retry logic without modifying original code.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How the Decorator pattern works in JavaScript
  • Building function decorators for logging, caching, and retries
  • Using the TC39 decorators proposal with classes

Prerequisites

  • JavaScript functions and closures
  • Basic understanding of classes

A decorator wraps a piece of functionality to extend or modify its behavior without changing the original code. In JavaScript, this pattern appears in two forms: function decorators (a well-established technique) and class decorators (a TC39 proposal now at Stage 3). Both are powerful tools for separation of concerns.

Function decorators

A function decorator is a higher-order function that takes a function, adds behavior, and returns a new function.

Basic structure

function withLogging(fn) {
  return function (...args) {
    console.log(`Calling ${fn.name} with`, args);
    const result = fn.apply(this, args);
    console.log(`${fn.name} returned`, result);
    return result;
  };
}

function add(a, b) {
  return a + b;
}

const loggedAdd = withLogging(add);
loggedAdd(2, 3);
// "Calling add with [2, 3]"
// "add returned 5"

The original add function is unchanged. The decorator wraps it with new behavior.

Memoization decorator

Caching expensive function results is one of the most common decorator use cases.

function memoize(fn) {
  const cache = new Map();

  return function (...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      return cache.get(key);
    }

    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// Without memoization: exponential time
// With memoization: linear time
fibonacci = memoize(fibonacci);
console.log(fibonacci(40)); // Instant

Retry decorator

Automatically retry a function that might fail due to transient errors.

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} failed: ${error.message}`);

        if (attempt < maxRetries) {
          await new Promise((r) => setTimeout(r, delayMs * attempt));
        }
      }
    }

    throw lastError;
  };
}

async function fetchUserData(userId) {
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

const resilientFetch = withRetry(fetchUserData, 3, 500);
const user = await resilientFetch(123);

Debounce decorator

function debounce(fn, delayMs) {
  let timeoutId;

  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
  };
}

const handleResize = debounce(() => {
  console.log("Window resized to", window.innerWidth);
}, 250);

window.addEventListener("resize", handleResize);

Composing decorators

Multiple decorators can be composed. The order matters — the outermost decorator runs first.

function compose(...decorators) {
  return function (fn) {
    return decorators.reduceRight((wrapped, decorator) => decorator(wrapped), fn);
  };
}

function withTiming(fn) {
  return async function (...args) {
    const start = performance.now();
    const result = await fn.apply(this, args);
    console.log(`${fn.name} took ${(performance.now() - start).toFixed(2)}ms`);
    return result;
  };
}

function withErrorBoundary(fn) {
  return async function (...args) {
    try {
      return await fn.apply(this, args);
    } catch (error) {
      console.error(`Error in ${fn.name}:`, error);
      return null;
    }
  };
}

// Apply multiple decorators
const enhance = compose(withErrorBoundary, withTiming, withRetry);
const robustFetch = enhance(fetchUserData);

Validation decorator

function validateArgs(...validators) {
  return function (fn) {
    return function (...args) {
      for (let i = 0; i < validators.length; i++) {
        if (validators[i] && !validators[i](args[i])) {
          throw new TypeError(
            `Argument ${i} failed validation in ${fn.name}`
          );
        }
      }
      return fn.apply(this, args);
    };
  };
}

const isString = (v) => typeof v === "string";
const isPositive = (v) => typeof v === "number" && v > 0;

const createUser = validateArgs(isString, isPositive)(
  function createUser(name, age) {
    return { name, age };
  }
);

createUser("Alice", 30);    // { name: "Alice", age: 30 }
createUser("Bob", -5);      // TypeError: Argument 1 failed validation

TC39 class decorators

The TC39 decorators proposal (Stage 3) brings decorator syntax to JavaScript classes. While not yet available in all environments without a transpiler, the syntax is stabilizing.

Method decorators

function logged(originalMethod, context) {
  const methodName = String(context.name);

  return function (...args) {
    console.log(`Entering ${methodName}`);
    const result = originalMethod.call(this, ...args);
    console.log(`Exiting ${methodName}`);
    return result;
  };
}

class Calculator {
  @logged
  add(a, b) {
    return a + b;
  }

  @logged
  multiply(a, b) {
    return a * b;
  }
}

const calc = new Calculator();
calc.add(2, 3);
// "Entering add"
// "Exiting add"

Class decorators

function singleton(Class, context) {
  let instance;

  return class extends Class {
    constructor(...args) {
      if (instance) return instance;
      super(...args);
      instance = this;
    }
  };
}

@singleton
class AppConfig {
  constructor() {
    this.settings = {};
  }
}

const a = new AppConfig();
const b = new AppConfig();
console.log(a === b); // true

Field decorators

function readonly(value, context) {
  return function (initialValue) {
    return initialValue;
  };
}

// With accessor keyword
function clamped(min, max) {
  return function (value, context) {
    return {
      get() {
        return value.get.call(this);
      },
      set(val) {
        value.set.call(this, Math.min(max, Math.max(min, val)));
      },
    };
  };
}

Method decorator without proposal syntax

You can apply the decorator pattern to class methods today without the Stage 3 syntax, using a utility function.

function decorateMethod(target, methodName, decorator) {
  const original = target.prototype[methodName];
  target.prototype[methodName] = decorator(original);
}

class UserService {
  async getUser(id) {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
  }
}

// Apply decorators without special syntax
decorateMethod(UserService, "getUser", withRetry);
decorateMethod(UserService, "getUser", withTiming);

When to use decorators

Decorators work best for cross-cutting concerns — behavior that applies across many functions or methods but is not part of their core logic:

  • Logging and tracing — record function calls without cluttering business logic.
  • Caching and memoization — avoid recomputation of expensive results.
  • Retry and resilience — handle transient failures in network calls.
  • Validation — enforce argument constraints at the boundary.
  • Access control — check permissions before method execution.
  • Rate limiting — throttle or debounce function calls.

Avoid over-decorating. If a function has five decorators stacked on it, the execution flow becomes hard to follow. Use decorators for genuine cross-cutting concerns, not as a substitute for clear function design.

Summary

The Decorator pattern extends functionality by wrapping rather than modifying:

  • Function decorators are higher-order functions that add behavior like logging, caching, retries, and validation.
  • Decorators compose naturally — compose(withRetry, withLogging)(fn) applies both.
  • The TC39 class decorators proposal (Stage 3) brings @decorator syntax to methods, fields, and classes.
  • You can apply the same pattern to class methods today without special syntax.
  • Use decorators for cross-cutting concerns to keep business logic clean and focused.