jsguides

The Decorator Pattern in JavaScript

What is the decorator pattern?

The Decorator Pattern is a design pattern that lets you add new behavior to objects dynamically, without changing the class of those objects. Think of it like wrapping a gift — the original object stays the same, but the wrapper adds new features around it.

This pattern is incredibly useful in JavaScript because it follows the Open/Closed Principle: your code stays open for extension but closed for modification. Instead of modifying existing functions or classes, you “decorate” them with additional functionality.

Why use decorators?

Imagine you have a simple function that fetches user data. Later, you realize you need to add logging, caching, and error handling. You could modify the original function, but that violates clean coding principles. Instead, you can create decorators — wrapper functions that add these behaviors while keeping the original intact.

The Decorator Pattern also helps you avoid the “inheritance explosion” problem. When you need many combinations of features, inheritance hierarchies can become unwieldy. Decorators let you stack behaviors like toppings on a pizza, combining them in any order without creating a new subclass for each permutation.

Function-based decorators

The most traditional approach to decorating in JavaScript involves wrapping functions with other functions. This technique has been used in JavaScript for years and works in any environment without special tooling.

Here is a simple example:

// Original function
function greet(name) {
  return `Hello, ${name}!`;
}

// Decorator function
function withLogging(fn) {
  return function(...args) {
    console.log(`Calling with args: ${args}`);
    const result = fn(...args);
    console.log(`Returned: ${result}`);
    return result;
  };
}

// Apply decorator
const greetWithLogging = withLogging(greet);
greetWithLogging('John');

// Output:
// Calling with args: John
// Returned: Hello, John!

The withLogging decorator wraps the original greet function. It logs the arguments before calling the function, then logs the result after. The original greet function remains unchanged — you can still call it directly if needed. This separation means you can add or remove logging without touching the core logic. A common pitfall: if the decorator does not return the result of fn(...args), the caller receives undefined instead of the expected value.

You can also create decorator factories — functions that return decorators with custom behavior:

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

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

const timedFibonacci = withTiming(calculateFibonacci);
timedFibonacci(10);
// Output: calculateFibonacci took 0.15ms
// Returns: 55

Class-based decorators

When working with ES6 classes, you can apply the decorator pattern using a base decorator class. This approach is common in component-based frameworks and follows object-oriented principles.

The classic example is adding toppings to coffee:

// Base component
class Coffee {
  getCost() { return 5; }
  getDescription() { return 'Plain coffee'; }
}

// Decorator base class
class CoffeeDecorator {
  constructor(coffee) { this.coffee = coffee; }
  getCost() { return this.coffee.getCost(); }
  getDescription() { return this.coffee.getDescription(); }
}

// Concrete decorators
class MilkDecorator extends CoffeeDecorator {
  getCost() { return this.coffee.getCost() + 1.5; }
  getDescription() { return `${this.coffee.getDescription()}, milk`; }
}

class CaramelDecorator extends CoffeeDecorator {
  getCost() { return this.coffee.getCost() + 2.5; }
  getDescription() { return `${this.coffee.getDescription()}, caramel`; }
}

// Usage
let coffee = new Coffee();
coffee = new MilkDecorator(coffee);
coffee = new CaramelDecorator(coffee);

console.log(coffee.getDescription());
// Output: Plain coffee, milk, caramel

console.log(coffee.getCost());
// Output: 9

Each decorator wraps the previous one and adds its own cost and description. The CoffeeDecorator base class delegates to the wrapped object by default, so concrete decorators only override the methods they want to change. You can stack decorators in any order, creating different combinations without creating new classes for each possibility. The key insight: the final object still looks like a Coffee to the rest of the code because every decorator implements the same interface.

TC39 decorator proposal

JavaScript is getting native decorator support through the TC39 proposal, currently at Stage 3. This means the syntax is finalized and awaiting inclusion in the language standard. You will need a transpiler like Babel to use it in production today.

Native decorators use the @ symbol before a function:

function readonly(target, name, descriptor) {
  descriptor.writable = false;
  return descriptor;
}

class User {
  constructor(name) { this.name = name; }
  
  @readonly
  getName() { return this.name; }
}

const user = new User('John');
user.getName = () => 'Jane'; // TypeError: Cannot assign to read only property

The readonly decorator modifies the property descriptor to make the method non-writable. This is just one line of code that replaces what would normally require much more setup using Object.defineProperty. A single decorator can replace several lines of descriptor manipulation.

Decorator factories

You can create parameterized decorators by returning a function from a factory:

function log(message) {
  return function(target, name, descriptor) {
    const original = descriptor.value;
    descriptor.value = function(...args) {
      console.log(`${message}: calling ${name}`);
      return original.apply(this, args);
    };
    return descriptor;
  };
}

class Calculator {
  @log('Math operation')
  add(a, b) { return a + b; }
  
  @log('Math operation')
  multiply(a, b) { return a * b; }
}

const calc = new Calculator();
calc.add(2, 3);
// Output: Math operation: calling add
// Returns: 5

The decorator factory log('Math operation') returns the actual decorator function, which then receives the method details. Factories give you reusable decorators with configurable behavior: the same log factory can produce decorators with different label strings for different contexts, keeping the wrapping logic in one place.

Common use cases

Logging

Decorators excel at adding logging without polluting your business logic:

function logged(target, name, descriptor) {
  const fn = descriptor.value;
  descriptor.value = function(...args) {
    console.log(`📝 Calling ${name}(${args.join(', ')})`);
    const result = fn.apply(this, args);
    console.log(`📝 ${name} returned: ${result}`);
    return result;
  };
  return descriptor;
}

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

const m = new Math();
m.add(2, 3);
// Output:
// 📝 Calling add(2, 3)
// 📝 add returned: 5

Caching

Memoization decorators save expensive computation results:

function cached(target, name, descriptor) {
  const cache = new Map();
  const fn = descriptor.value;
  
  descriptor.value = function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      console.log('⚡ Cache hit!');
      return cache.get(key);
    }
    console.log('🔄 Computing...');
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
  return descriptor;
}

class Fibonacci {
  @cached
  calculate(n) {
    if (n <= 1) return n;
    return this.calculate(n - 1) + this.calculate(n - 2);
  }
}

const fib = new Fibonacci();
console.log(fib.calculate(10));
// Output:
// 🔄 Computing...
// 55
console.log(fib.calculate(10));
// Output:
// ⚡ Cache hit!
// 55

Validation

Add runtime validation to method parameters:

function validate(validator) {
  return function(target, name, descriptor) {
    const fn = descriptor.value;
    descriptor.value = function(...args) {
      if (!validator(...args)) {
        throw new Error(`Validation failed for ${name}`);
      }
      return fn.apply(this, args);
    };
    return descriptor;
  };
}

class User {
  @validate(age => age >= 18)
  setAge(age) { 
    this.age = age; 
    return this.age;
  }
}

const user = new User();
user.setAge(25); // Works fine
user.setAge(16); // Throws Error: Validation failed for setAge

Stacking decorators carefully

Decorators often become more useful when they are stacked, but order matters. A logging decorator around a caching decorator does not behave the same way as a caching decorator around logging. One records every call, while the other may skip repeated work entirely. That means the stack is part of the design, not just a syntactic detail.

When you stack decorators, keep each one small and predictable. A decorator that changes too many things at once becomes hard to compose with others. The best decorators are the ones that do one job well and leave the rest of the method behavior intact. That makes it easier to reason about the combined effect.

Composition versus inheritance

The decorator pattern is often presented as an alternative to inheritance, and that is a helpful way to think about it. Inheritance works best when the type relationship is truly “is a”. Decoration works best when you want to add behavior without changing the underlying identity of the object. That distinction matters when you are designing reusable code.

Composition is usually easier to adjust over time because each wrapper can be added or removed without changing the base type. It also lets you mix behaviors in different combinations. That flexibility makes decorators a good fit for features like logging, timing, and validation, where the core object should stay the same while the supporting behavior changes.

Where the pattern fits best

Decorators are most helpful when you have a stable core object and a few cross-cutting behaviors that may change from one situation to another. They are less useful when the new behavior is really part of the main object and should live there directly. In that case, adding another wrapper only makes the design harder to follow.

As a rule of thumb, prefer decoration when you can describe the added behavior in a sentence without redefining the object itself. If the description sounds like “the same thing, but with logging,” or “the same thing, but with caching,” the pattern is probably a good fit. If the description sounds like a new kind of object, a separate type may be clearer.

Keeping decoration predictable

A decorator should be easy to remove without changing the meaning of the base object. That predictability matters when several features share the same code path. If one wrapper has hidden side effects, it becomes much harder to reuse the rest of the stack with confidence.

The simplest decorators tend to be the best ones. They do one thing, they return a clear result, and they leave the original object shape easy to understand. That restraint makes the pattern easier to use in real applications where several people will touch the same code over time.

See Also