Author: Artem Khrienov
Url: https://medium.com/@artemkhrenov/the-decorator-pattern-in-modern-javascript-adding-functionality-without-breaking-code-b43d9c237047
Date Published: March 24, 2025
Content: The Decorator Pattern in Modern JavaScript: Adding Functionality Without Breaking Code
The Decorator Pattern in Modern JavaScript: Adding Functionality Without Breaking Code
Ever been in a situation where you need to add functionality to an existing object but can’t modify its code? Or maybe you’ve created the perfect class but later realized you need several variations of it with different capabilities? If you’ve faced these challenges, the Decorator pattern might be exactly what you need.
In this article, I’ll walk you through the Decorator pattern in JavaScript - a powerful yet often underutilized design pattern that allows you to add behavior to objects dynamically without affecting other objects of the same class. We’ll explore both traditional implementations and modern JavaScript approaches, including the exciting Stage 3 Decorator proposal.
JavaScript Design Patterns Article Series
Git Repository: https://github.com/akhrienov/javascript-design-patterns/tree/main/patterns/structural/decorator
What Is the Decorator Pattern?
At its core, the Decorator pattern is about adding responsibilities to objects dynamically. Think of it like wrapping a gift: the original object (the gift) remains unchanged, but the wrapper adds new characteristics to it.
Unlike inheritance, which extends an object’s behavior at compile time, decorators let you add behavior at runtime. This gives you much more flexibility and helps you avoid the “class explosion” problem where you need to create many subclasses to handle various combinations of behaviors.
Why Use Decorators?
There are several compelling reasons to use the Decorator pattern:
Open/Closed Principle: They allow you to extend an object’s behavior without modifying its core code.
Composition Over Inheritance: They provide an alternative to subclassing for extending functionality.
Runtime Flexibility: You can add or remove responsibilities from an object at runtime.
Combinable Behaviors: You can combine multiple decorators to create complex behaviors from simple ones.
Single Responsibility: Each decorator handles just one aspect of functionality.
Let’s make this concrete with a simple analogy. Imagine you have a basic coffee. You can “decorate” it by adding milk, sugar, caramel, or whipped cream in any combination without changing what makes it coffee in the first place.
Traditional JavaScript Decorators
Before we dive into the modern JavaScript decorators proposal, let’s understand how we can implement the Decorator pattern using plain JavaScript.
The simplest form of decoration in JavaScript is function decoration - wrapping a function with another function to extend its behavior. This technique is widely used in JavaScript and forms the basis of concepts like higher-order functions.
Here’s a basic example of function decoration:
// Our original function
function greet(name) {
return `Hello, ${name}!`;
}
// A decorator function that adds logging
function withLogging(fn) {
// This is the wrapper function that "decorates" the original
return function(...args) {
// Log before calling the function
console.log(`Calling function with args: ${args}`);
// Call the original function
const result = fn(...args);
// Log after calling the function
console.log(`Function returned: ${result}`);
// Return the original result
return result;
};
}
// Apply the decorator
const greetWithLogging = withLogging(greet);
// Use the decorated function
greetWithLogging('John');
// Logs: Calling function with args: John
// Logs: Function returned: Hello, John!
// Returns: "Hello, John!"
In this example, withLogging is a decorator that adds logging functionality to any function it wraps. When we call greetWithLogging('John'), we get the same result as calling the original greet('John'), but with additional logging behavior.
This is a powerful technique because:
Let’s explore a more practical example that you might encounter in real-world development:
// Function that makes an API request
async function fetchData(url) {
const response = await fetch(url);
return response.json();
}
// Decorator for adding retry capability
function withRetry(fn, maxRetries = 3) {
return async function(...args) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn(...args);
} catch (error) {
console.log(`Attempt ${attempt + 1} failed, retrying...`);
lastError = error;
// Wait a bit longer between each retry
await new Promise(resolve => setTimeout(resolve, 300 * Math.pow(2, attempt)));
}
}
throw new Error(`Max retries reached. Last error: ${lastError.message}`);
};
}
// Decorator for caching results
function withCache(fn, ttlMs = 60000) {
const cache = new Map();
return async function(...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < ttlMs) {
console.log('Cache hit!');
return cached.value;
}
console.log('Cache miss, fetching fresh data...');
const result = await fn(...args);
cache.set(key, {
value: result,
timestamp: Date.now()
});
return result;
};
}
// Apply multiple decorators
const enhancedFetch = withCache(withRetry(fetchData));
// Usage
async function main() {
// First call - will fetch from API
const data1 = await enhancedFetch('https://api.example.com/data');
// Second call - will use cached data
const data2 = await enhancedFetch('https://api.example.com/data');
}
In this example, we’ve created two decorators:
withRetry - adds automatic retry functionality for failed API calls
withCache - caches results to avoid unnecessary API calls
By combining these decorators, we’ve transformed a simple fetchData function into a robust enhancedFetch function with retry and caching capabilities, all without modifying the original function.
We can also apply the Decorator pattern to objects and classes in JavaScript. Here’s how we can decorate an object:
// Our base object
const car = {
model: 'Basic Model',
price: 20000,
getDescription() {
return `${this.model} costs $${this.price}`;
}
};
// A decorator function for adding AC
function withAC(car) {
const decoratedCar = Object.create(car);
decoratedCar.hasAC = true;
decoratedCar.price = car.price + 1500;
const originalGetDescription = car.getDescription;
decoratedCar.getDescription = function() {
return `${originalGetDescription.call(this)} and includes AC`;
};
return decoratedCar;
}
// A decorator function for adding premium sound
function withPremiumSound(car) {
const decoratedCar = Object.create(car);
decoratedCar.hasPremiumSound = true;
decoratedCar.price = car.price + 2000;
const originalGetDescription = car.getDescription;
decoratedCar.getDescription = function() {
return `${originalGetDescription.call(this)} and includes premium sound`;
};
return decoratedCar;
}
// Create decorated cars
const carWithAC = withAC(car);
const carWithPremiumSound = withPremiumSound(car);
const fullyLoadedCar = withPremiumSound(withAC(car));
console.log(car.getDescription());
// "Basic Model costs $20000"
console.log(carWithAC.getDescription());
// "Basic Model costs $21500 and includes AC"
console.log(fullyLoadedCar.getDescription());
// "Basic Model costs $23500 and includes AC and includes premium sound"
Using ES6 classes, we can implement the classic decorator pattern more formally:
// Base component class
class Coffee {
getCost() {
return 5; // base cost
}
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()}, with milk`;
}
}
class WhippedCreamDecorator extends CoffeeDecorator {
getCost() {
return this.coffee.getCost() + 2;
}
getDescription() {
return `${this.coffee.getDescription()}, with whipped cream`;
}
}
class CaramelDecorator extends CoffeeDecorator {
getCost() {
return this.coffee.getCost() + 2.5;
}
getDescription() {
return `${this.coffee.getDescription()}, with caramel`;
}
}
// Usage
const plainCoffee = new Coffee();
console.log(plainCoffee.getDescription()); // "Plain coffee"
console.log(`Cost: $${plainCoffee.getCost()}`); // "Cost: $5"
const milkCoffee = new MilkDecorator(plainCoffee);
console.log(milkCoffee.getDescription()); // "Plain coffee, with milk"
console.log(`Cost: $${milkCoffee.getCost()}`); // "Cost: $6.5"
// We can stack decorators
const fancyCoffee = new CaramelDecorator(new WhippedCreamDecorator(new MilkDecorator(plainCoffee)));
console.log(fancyCoffee.getDescription()); // "Plain coffee, with milk, with whipped cream, with caramel"
console.log(`Cost: $${fancyCoffee.getCost()}`); // "Cost: $11"
This coffee example shows the classic implementation of the Decorator pattern that you might find in traditional object-oriented languages. The important aspects to note are:
Modern JavaScript Decorators (Stage 3 Proposal)
Now let’s dive into one of the most exciting features coming to JavaScript: the Decorators proposal. Currently at Stage 3 in the TC39 process, this feature brings native decorator syntax to JavaScript, similar to what’s available in languages like Python and TypeScript.
JavaScript decorators are special functions that can modify classes and class members. They use the @ symbol followed by the decorator name, placed just before the declaration they're decorating.
Let’s look at a simple example:
// A decorator 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');
console.log(user.getName()); // "John"
// This will throw an error because getName is now read-only
user.getName = function() { return 'Jane'; }; // Error: Cannot assign to read only property
In this example, the @readonly decorator makes the getName method non-writable, preventing it from being modified after the class is defined.
Decorator Factories
Decorators can also be factory functions that return the actual decorator function. This allows us to pass parameters to customize the decorator’s behavior:
// A decorator factory
function log(message) {
// This is the actual decorator function
return function(target, name, descriptor) {
// Save a reference to the original method
const originalMethod = descriptor.value;
// Replace the method with a new one that logs before calling the original
descriptor.value = function(...args) {
console.log(`${message} (Args: ${args})`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}
class Calculator {
@log('Adding numbers')
add(a, b) {
return a + b;
}
@log('Multiplying numbers')
multiply(a, b) {
return a * b;
}
}
const calc = new Calculator();
calc.add(2, 3); // Logs: "Adding numbers (Args: 2,3)" and returns 5
calc.multiply(2, 3); // Logs: "Multiplying numbers (Args: 2,3)" and returns 6
Class decorators are applied to the entire class. They receive the constructor function as their argument and can modify the class or even replace it with a new class:
// A class decorator
function singleton(Class) {
// Keep an instance cache
const instanceCache = new WeakMap();
// Return a proxy to handle instantiation
return new Proxy(Class, {
construct(target, args) {
// If we don't have an instance yet, create one
if (!instanceCache.has(target)) {
instanceCache.set(target, new target(...args));
}
// Return the cached instance
return instanceCache.get(target);
}
});
}
@singleton
class Database {
constructor(uri) {
this.uri = uri;
console.log(`Connecting to database at ${uri}`);
}
query(sql) {
console.log(`Executing SQL: ${sql}`);
return [`result1`, `result2`];
}
}
// These two instances are actually the same object
const db1 = new Database('mongodb://localhost:27017');
const db2 = new Database('mongodb://localhost:27017');
console.log(db1 === db2); // true
// The constructor log only appears once, proving it's a singleton
Method decorators apply to class methods. They receive the class prototype, the method name, and a property descriptor:
// A method decorator for measuring execution time
function measure(target, name, descriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args) {
const start = performance.now();
const result = originalMethod.apply(this, args);
const end = performance.now();
console.log(`${name} took ${end - start}ms to execute`);
return result;
};
return descriptor;
}
class SortAlgorithms {
@measure
bubbleSort(arr) {
// Implementation of bubble sort
const result = [...arr];
// ... bubble sort logic
return result;
}
@measure
quickSort(arr) {
// Implementation of quick sort
const result = [...arr];
// ... quick sort logic
return result;
}
}
const sorter = new SortAlgorithms();
sorter.bubbleSort([5, 3, 8, 1, 2, 4]);
// Logs: "bubbleSort took 0.34ms to execute"
sorter.quickSort([5, 3, 8, 1, 2, 4]);
// Logs: "quickSort took 0.12ms to execute"
Property decorators apply to class properties. They work similarly to method decorators but are applied to class fields:
// A property decorator for validation
function minLength(length) {
return function(target, name) {
// Get the original descriptor
let value;
// Define a new property with getter and setter
Object.defineProperty(target, name, {
get() {
return value;
},
set(newValue) {
if (newValue.length < length) {
throw new Error(`${name} must be at least ${length} characters long`);
}
value = newValue;
}
});
};
}
class User {
@minLength(3)
username = '';
constructor(username) {
this.username = username;
}
}
// This works
const user1 = new User('John');
// This throws an error
try {
const user2 = new User('Jo');
} catch (error) {
console.error(error.message); // "username must be at least 3 characters long"
}
Real-World Example: Authentication
Let’s look at a comprehensive real-world example using decorators for authentication. This is a common use case where we want to ensure certain methods or API endpoints are only accessible to authenticated users with specific roles:
// Decorator factory for role-based authorization
function authorize(requiredRole) {
return function(target, name, descriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args) {
// Check if the user is logged in and has the required role
if (!this.currentUser) throw new Error('Authentication required');
if (this.currentUser.role !== requiredRole) throw new Error(`Requires ${requiredRole} role`);
// If authorized, call the original method
return originalMethod.apply(this, args);
};
return descriptor;
};
}
// Class decorator for adding authentication capabilities
function authenticatable(Class) {
return class extends Class {
constructor(...args) {
super(...args);
this.currentUser = null;
}
login(user) {
console.log(`User ${user.name} logged in with role ${user.role}`);
this.currentUser = user;
}
logout() {
console.log(`User ${this.currentUser.name} logged out`);
this.currentUser = null;
}
};
}
// Our API class with protected methods
@authenticatable
class UserAPI {
// Anyone can get public user profiles
getPublicProfile(userId) {
return { id: userId, name: 'Public User' };
}
// Only admins can delete users
@authorize('admin')
deleteUser(userId) {
console.log(`Deleting user ${userId}...`);
return { success: true };
}
// Only the user or an admin can update a user's profile
@authorize('user')
updateUserProfile(userId, data) {
console.log(`Updating user ${userId} with ${JSON.stringify(data)}...`);
return { success: true };
}
}
// Usage
const api = new UserAPI();
// This works for anyone
api.getPublicProfile(123);
// This fails because we're not logged in
try {
api.deleteUser(123);
} catch (error) {
console.error(error.message); // "Authentication required"
}
// Log in as a regular user
api.login({ name: 'John', role: 'user' });
// This fails because we don't have admin role
try {
api.deleteUser(123);
} catch (error) {
console.error(error.message); // "Requires admin role"
}
// This works because we're logged in as a user
api.updateUserProfile(123, { name: 'John Doe' });
// Log in as an admin
api.logout();
api.login({ name: 'Admin', role: 'admin' });
// Now this works
api.deleteUser(123);
This example demonstrates a practical application of the Decorator pattern for authentication and authorization. We use:
This approach makes the code more maintainable and expressive than embedding authentication logic directly in each method.
Performance Impact Analysis
As with any pattern, using decorators comes with some performance considerations. Let’s analyze the potential impact:
Each decorator adds a function call to the execution chain. While a single decorator has minimal impact, multiple nested decorators can add up:
// Performance test
function measurePerformance(fn, iterations = 1000000) {
const start = performance.now();
for (let i = 0; i < iterations; i++) {
fn();
}
const end = performance.now();
return end - start;
}
function simpleFunction() {
return 42;
}
function singleDecorator(fn) {
return function() {
return fn();
};
}
const decorated1 = singleDecorator(simpleFunction);
const decorated2 = singleDecorator(singleDecorator(simpleFunction));
const decorated5 = singleDecorator(singleDecorator(singleDecorator(singleDecorator(singleDecorator(simpleFunction)))));
console.log(`Simple function: ${measurePerformance(simpleFunction)}ms`);
console.log(`1 decorator: ${measurePerformance(decorated1)}ms`);
console.log(`2 decorators: ${measurePerformance(decorated2)}ms`);
console.log(`5 decorators: ${measurePerformance(decorated5)}ms`);
Simple function: 35.10099697113037ms
1 decorator: 10.870450973510742ms
2 decorators: 17.459564208984375ms
5 decorators: 34.91532588005066ms
Results typically show that each level of decoration adds a small but measurable overhead. However, for most applications, this overhead is negligible compared to other operations like DOM manipulation or network requests.
Memory Considerations
Decorators can increase memory usage due to function closures capturing variables in their scope. This is rarely a problem for modern JavaScript engines, but it’s something to be aware of in memory-constrained environments.
Mitigating Performance Impact
Here are some strategies to minimize the performance impact of decorators:
Use them judiciously: Only decorate methods that need the additional functionality
Combine decorators: If possible, combine multiple decorators into a single one
Cache results: For expensive operations, consider caching results
Optimize hot paths: Avoid using decorators in performance-critical loops
Best Practices for Using Decorators
To get the most out of the Decorator pattern, follow these best practices:
Each decorator should have a single responsibility. This makes them more reusable and easier to understand:
// Good: Focused decorators
function withLogging(fn) { /* ... */ }
function withRetry(fn) { /* ... */ }
// Not as good: Mixed responsibilities
function withLoggingAndRetry(fn) { /* ... */ }
Decorators should maintain the same interface as the objects they decorate. This ensures that decorated objects can be used in place of the original objects:
// Good: Same interface
function withCache(fn) {
return function(...args) {
// Cache logic
return fn(...args);
};
}
// Not as good: Different interface
function withCache(fn) {
return {
execute: function(...args) {
// Cache logic
return fn(...args);
}
};
}
Decorators can make code harder to understand if their behavior isn’t well-documented. Always document what your decorators do and how they affect the decorated object:
/**
* Adds retry capability to any async function.
* Will retry the function up to 'maxRetries' times with exponential backoff.
*
* @param {Function} fn - The function to retry
* @param {number} maxRetries - Maximum number of retry attempts
* @returns {Function} - The decorated function with retry capability
*/
function withRetry(fn, maxRetries = 3) {
// ...
}
When applying multiple decorators, be mindful of the order of application. Decorators are applied from bottom to top in the code and from inside to outside in the execution:
// Order matters!
@withAuth
@withLogging
class Api {
// ...
}
// This means:
// 1. First 'withLogging' is applied
// 2. Then 'withAuth' is applied to the result
// So 'withAuth' will see the logging behavior too
When testing, it’s a good practice to test both the original object and the decorated object separately:
// Test original function
test('fetchData should return JSON data', async () => {
// ...
});
// Test decorated function
test('fetchData with retry should retry on failure', async () => {
// ...
});
Conclusion
The Decorator pattern is a powerful technique for extending object functionality without modifying the original code. JavaScript’s flexible nature makes it particularly well-suited for this pattern, especially with the upcoming native decorators proposal.
Key takeaways from this article:
Whether you use traditional function wrapping, class composition, or the new decorator syntax, this pattern can help you write more modular, maintainable, and flexible code. As JavaScript continues to evolve, the Decorator pattern will likely become even more prominent in modern applications.
So next time you find yourself needing to add behavior to an object without modifying its code, remember the Decorator pattern, it might just be the perfect solution!
Happy Coding!