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.
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’s 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.
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. You can stack decorators in any order, creating different combinations without creating new classes for each possibility.
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’ll 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.
Decorator Factories
You can create parameterized decorators by returning a function:
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.
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
See Also
- JavaScript Classes — Learn about ES6 classes, constructors, and inheritance
- The Module Pattern — Another way to organize code using closures and IIFEs
- JavaScript Design Patterns — Overview of common patterns including Singleton, Observer, and Factory