Davidson Woods Family Website
Login
  • Blogs
  • Recipes
  • Stories
  • Photo Gallery
  • Articles
Home>articles>68c722ee98f456b8529de82e
  • 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

    Understanding Design Patterns in Modern JavaScript
    JavaScript OOP Fundamentals for Design Patterns
    Creational Patterns in Modern JavaScript
    Structural Design Patterns in JavaScript
    Behavioral Design Patterns in JavaScript

    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.

    Function Decoration

    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:

    The original function remains unchanged
    We can apply the decorator selectively to only the functions that need it
    We can combine multiple decorators for compound effects
    We can add or remove decorators at runtime

    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.

    Object and Class Decoration

    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:

    We have a base component (Coffee)
    We have a decorator base class that implements the same interface
    We have concrete decorators that add functionality
    We can stack decorators to build complex objects from simple ones

    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.

    Note: As the JavaScript Decorators proposal is at Stage 3 (as of March 2025), the syntax might still undergo minor changes before becoming part of the ECMAScript standard. You might need Babel or TypeScript to use this syntax until it’s fully supported in browsers.
    What Are JavaScript Decorators?

    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

    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

    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

    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:

    A class decorator (@authenticatable) to add authentication capabilities to our API class
    A method decorator (@authorize) to protect specific methods based on user roles

    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:

    Function Wrapping Overhead

    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:

    Keep Decorators Focused

    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) { /* ... */ }
    Preserve the Original Interface

    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);
        }
      };
    }
    Document Decorator Behavior

    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) {
      // ...
    }
    Consider Decorator Composition Order

    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
    Test Decorated Objects Separately

    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:

    The Decorator Pattern lets you add behavior to objects dynamically without affecting other objects.
    Function decoration is a fundamental JavaScript technique used in many frameworks and libraries.
    Object and class decoration can be implemented in various ways, from simple object composition to formal class hierarchies.
    Modern JavaScript decorators (Stage 3 proposal) provide a clean, expressive syntax for implementing decorators.
    Real-world use cases like authentication demonstrate the practical value of decorators.
    While decorators have some performance overhead, it’s typically negligible in most applications.

    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!

    Reply Return to List Page
  • About us
  • Contact

© by Mark Davidson

All rights reserved.