Skip to content
JavaScript js patterns 4 min read

Decorator Pattern

The Decorator pattern lets you attach new behavior to an existing function or object by wrapping it, instead of editing the original code. The wrapper exposes the same interface as the thing it decorates, so callers never know the difference — they just get logging, timing, caching, or validation for free. In JavaScript this pattern is everywhere because functions are first-class values, which makes higher-order functions the most natural way to express it. It keeps the core logic clean and lets you compose cross-cutting concerns on top.

Decorating functions with higher-order functions

A function decorator is a higher-order function: it takes a function and returns a new function with the same signature plus extra behavior. The classic example is logging. The wrapper records the call, delegates to the original, and returns the result untouched.

const withLogging = (fn) => (...args) => {
  console.log(`Calling ${fn.name}(${args.join(", ")})`);
  const result = fn(...args);
  console.log(`-> ${result}`);
  return result;
};

const add = (a, b) => a + b;
const loggedAdd = withLogging(add);

loggedAdd(2, 3);

Output:

Calling add(2, 3)
-> 5

Because the decorator preserves the call signature, loggedAdd is a drop-in replacement for add. You can apply it anywhere the original was used without changing the call sites.

Timing decorator

Measuring how long a function takes is another cross-cutting concern that fits the pattern perfectly. The decorator wraps the call in performance.now() reads (available in both modern browsers and Node).

const withTiming = (fn) => (...args) => {
  const start = performance.now();
  const result = fn(...args);
  const ms = (performance.now() - start).toFixed(2);
  console.log(`${fn.name} took ${ms}ms`);
  return result;
};

const slowSquare = (n) => {
  for (let i = 0; i < 1e7; i++) {} // simulate work
  return n * n;
};

withTiming(slowSquare)(9);

For async functions, make the wrapper async and await the inner call so the timing covers the whole operation:

const withAsyncTiming = (fn) => async (...args) => {
  const start = performance.now();
  const result = await fn(...args);
  console.log(`${fn.name} took ${(performance.now() - start).toFixed(2)}ms`);
  return result;
};

Caching (memoization) decorator

A caching decorator stores results keyed by the arguments, so repeated calls with the same input skip the work entirely. This is memoization expressed as a decorator.

const withCache = (fn) => {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
};

const fib = withCache((n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
console.log(fib(40));

Output:

102334155

Using JSON.stringify for the cache key only works for serializable arguments. For object identity or functions as arguments, reach for a WeakMap keyed on the object instead.

Composing decorators

Since each decorator returns a function of the same shape, you can stack them. A small compose helper applies them right-to-left, so the rightmost wraps closest to the original.

const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);

const enhanced = compose(withLogging, withTiming, withCache)(slowSquare);
enhanced(12);

Order matters: here withCache is innermost (so cached calls also skip timing), and withLogging is outermost.

Decorating objects

Decorators are not limited to functions. You can wrap an object to extend or override behavior while delegating the rest, often via a Proxy so unknown members pass through automatically.

const createUser = (name) => ({
  name,
  greet() {
    return `Hi, I'm ${this.name}`;
  },
});

const withTimestamps = (obj) =>
  new Proxy(obj, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      if (typeof value === "function") {
        return (...args) => {
          console.log(`[${new Date().toISOString()}] ${String(prop)}()`);
          return value.apply(target, args);
        };
      }
      return value;
    },
  });

const user = withTimestamps(createUser("Ada"));
console.log(user.greet());

The proxy intercepts method access, logs the call, then forwards to the real method. Non-function properties are returned as-is.

The decorator syntax proposal

A dedicated @decorator syntax reached Stage 3 of TC39 and ships natively in TypeScript and via Babel for plain JavaScript. It applies declaratively to classes, methods, fields, and accessors.

function logged(value, { kind, name }) {
  if (kind === "method") {
    return function (...args) {
      console.log(`calling ${name}`);
      return value.call(this, ...args);
    };
  }
}

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

new Calculator().multiply(6, 7);

The decorator function receives the original value plus a context object describing what it decorates, and returns a replacement. It is the same wrapping idea, expressed as syntax.

ApproachBest forNative today
Higher-order functionWrapping standalone functionsYes
Proxy wrapperDecorating object instancesYes
@decorator syntaxClass methods/fieldsTS / Babel

Best Practices

  • Keep the decorated function’s signature identical so wrappers stay drop-in replacements.
  • Always return the inner function’s result; forgetting to return silently breaks behavior.
  • Copy fn.name or use a named wrapper when stack traces and logs need the original name.
  • Make async-aware decorators async and await the inner call so behavior spans the whole operation.
  • Cache keys must capture all relevant arguments; prefer WeakMap when keying on objects.
  • Compose small single-purpose decorators rather than one decorator that does many things.
Last updated June 1, 2026
Was this helpful?