Advanced JavaScript Patterns: Singleton, Decorator, and Proxy


JavaScript is a versatile programming language that allows developers to create dynamic and interactive web applications. With its vast array of features and flexibility, JavaScript enables the implementation of various design patterns to solve complex problems efficiently. In this article, we will explore three advanced JavaScript patterns: Singleton, Decorator, and Proxy. These patterns provide elegant solutions for managing object creation, adding functionality dynamically, and controlling access to objects.

Singleton Pattern

The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. This pattern is useful when we want to limit the number of instances of a class to prevent redundancy or ensure that a single instance is shared across the application.

Example

Let's consider an example where we want to create a logger object that maintains a single instance throughout the application:

// Singleton Logger
const Logger = (() => {
   let instance;
  
   function createInstance() {
      const log = [];

      return {
         addLog: (message) => {
            log.push(message);
         },
         printLogs: () => {
            log.forEach((message) => {
               console.log(message);
            });
         },
      };
   }

   return {
      getInstance: () => {
         if (!instance) {
            instance = createInstance();
         }
         return instance;
      },
   };
})();

// Usage
const logger1 = Logger.getInstance();
const logger2 = Logger.getInstance();

logger1.addLog("Message 1");
logger2.addLog("Message 2");

logger1.printLogs(); 

Explanation

In this example, we use an immediately-invoked function expression (IIFE) to encapsulate the Singleton implementation. The getInstance method checks if an instance of the logger exists. If it does not, it creates a new instance using the createInstance function. This ensures that only one instance of the logger is created and shared among all the calls to getInstance. The logger instance maintains an internal log array, allowing messages to be added and printed.

Decorator Pattern

The Decorator pattern allows us to add new behaviors or modify existing ones dynamically to an object without affecting other instances of the same class. It enables us to extend the functionality of an object by wrapping it with one or more decorators, providing a flexible alternative to subclassing.

Example

Let's consider an example where we have a simple car object and want to add additional features using decorators:

// Car class
class Car {
   constructor() {
      this.description = "Basic car";
   }
   
   getDescription() {
      return this.description;
   }
}

// Decorator class
class CarDecorator {
   constructor(car) {
      this.car = car;
   }

   getDescription() {
      return this.car.getDescription();
   }
}

// Decorators
class ElectricCarDecorator extends CarDecorator {
   constructor(car) {
      super(car);
   }

   getDescription() {
      return `${super.getDescription()}, electric engine`;
   }
}

class LuxuryCarDecorator extends CarDecorator {
   constructor(car) {
      super(car);
   }

   getDescription() {
      return `${super.getDescription()}, leather seats`;
   }
}

// Usage
const basicCar = new Car();
const electricLuxuryCar = new ElectricCarDecorator(
   new LuxuryCarDecorator(basicCar)
);

console.log(electricLuxuryCar.getDescription());

Explanation

In this example, we start with a Car class representing a basic car with a getDescription method. The CarDecorator class serves as the base decorator, providing a common interface for all decorators. Each specific decorator extends the CarDecorator and overrides the getDescription method to add or modify functionality.

We create two specific decorators: ElectricCarDecorator and LuxuryCarDecorator. The ElectricCarDecorator adds an electric engine feature, while the LuxuryCarDecorator adds leather seats. By combining these decorators, we can enhance the car object with multiple features dynamically.

Proxy Pattern

The Proxy pattern allows us to control access to an object by providing a surrogate or placeholder. It can be used to add additional logic, such as validation or caching, without modifying the original object's behaviour. Proxies act as intermediaries between clients and the actual objects, enabling fine-grained control over object interactions.

Example

Let's consider an example where we use a proxy to implement simple access control for a user object:

// User object
const user = {
   name: "John",
   role: "user",
};

// Proxy
const userProxy = new Proxy(user, {
   get: (target, property) => {
      if (property === "role" && target.role === "admin") {
         throw new Error("Access denied");
      }

      return target[property];
   },
});

console.log(userProxy.name); 
console.log(userProxy.role); 

Explanation

In this example, we have a user object with properties name and role. We create a proxy using the Proxy constructor, providing a handler object with a get trap. The get trap intercepts property access and allows us to implement custom logic. In this case, we check if the property being accessed is role and if the user has the "admin" role. If the condition is met, we throw an error to deny access; otherwise, we allow the property access.

Conclusion

Understanding advanced JavaScript patterns such as Singleton, Decorator, and Proxy opens up a new world of possibilities for developers. These patterns provide elegant solutions to manage object creation, dynamically add functionality, and control access to objects. By leveraging these patterns, developers can write cleaner, more maintainable code that is flexible and extensible.

By implementing the Singleton pattern, we ensure that only one instance of a class exists throughout the application, preventing redundancy and allowing shared resources. The Decorator pattern allows us to dynamically add or modify the behaviour of an object without affecting other instances. Finally, the Proxy pattern gives us fine-grained control over object access, allowing us to enforce rules and implement additional logic.

By incorporating these advanced JavaScript patterns into our codebase, we can build more robust and scalable applications, making our code more flexible and maintainable in the long run.

Updated on: 24-Jul-2023

114 Views

Kickstart Your Career

Get certified by completing the course

Get Started
Advertisements