Explore practical applications of the Decorator pattern in JavaScript and TypeScript, including logging, validation, and security enhancements.
The Decorator pattern is a structural design pattern that allows behavior to be added to individual objects, either statically or dynamically, without affecting the behavior of other objects from the same class. This pattern is particularly useful in scenarios where you need to add responsibilities to objects without modifying their code, thus adhering to the Open/Closed Principle. In this section, we’ll explore practical applications of the Decorator pattern in JavaScript and TypeScript, including logging, validation, and security enhancements.
Before diving into use cases, let’s briefly revisit what the Decorator pattern entails. The pattern involves a set of decorator classes that are used to wrap concrete components. Decorators provide a flexible alternative to subclassing for extending functionality.
Here’s a simple UML diagram to illustrate the Decorator pattern:
classDiagram class Component { <<interface>> +operation()* } class ConcreteComponent { +operation() } class Decorator { -component: Component +operation()* } class ConcreteDecoratorA { +operation() } class ConcreteDecoratorB { +operation() } Component <|-- ConcreteComponent Component <|-- Decorator Decorator <|-- ConcreteDecoratorA Decorator <|-- ConcreteDecoratorB Decorator o-- Component
In this diagram:
Component
is an interface or abstract class defining the operation.ConcreteComponent
is a class that implements the Component
interface.Decorator
is an abstract class that implements the Component
interface and contains a reference to a Component
object.ConcreteDecoratorA
and ConcreteDecoratorB
are classes that extend Decorator
and add additional behavior.Logging is a common requirement in software development, and the Decorator pattern provides an elegant solution for adding logging functionality to existing methods or classes without altering their structure.
class UserService {
getUser(id) {
return { id, name: "John Doe" };
}
}
function loggingDecorator(originalMethod) {
return function (...args) {
console.log(`Calling ${originalMethod.name} with arguments: ${JSON.stringify(args)}`);
const result = originalMethod.apply(this, args);
console.log(`Result: ${JSON.stringify(result)}`);
return result;
};
}
UserService.prototype.getUser = loggingDecorator(UserService.prototype.getUser);
const userService = new UserService();
userService.getUser(1);
In this example, the loggingDecorator
function wraps the getUser
method, adding logging before and after the method execution.
class UserService {
getUser(id: number): { id: number; name: string } {
return { id, name: "John Doe" };
}
}
function loggingDecorator<T extends Function>(originalMethod: T): T {
return function (...args: any[]) {
console.log(`Calling ${originalMethod.name} with arguments: ${JSON.stringify(args)}`);
const result = originalMethod.apply(this, args);
console.log(`Result: ${JSON.stringify(result)}`);
return result;
} as T;
}
UserService.prototype.getUser = loggingDecorator(UserService.prototype.getUser);
const userService = new UserService();
userService.getUser(1);
Validation is another area where the Decorator pattern shines. By using decorators, we can add validation logic to methods without cluttering the core business logic.
class OrderService {
placeOrder(order) {
console.log("Order placed:", order);
}
}
function validationDecorator(originalMethod) {
return function (order) {
if (!order || !order.item || order.quantity <= 0) {
throw new Error("Invalid order");
}
return originalMethod.apply(this, arguments);
};
}
OrderService.prototype.placeOrder = validationDecorator(OrderService.prototype.placeOrder);
const orderService = new OrderService();
orderService.placeOrder({ item: "Laptop", quantity: 1 });
interface Order {
item: string;
quantity: number;
}
class OrderService {
placeOrder(order: Order): void {
console.log("Order placed:", order);
}
}
function validationDecorator<T extends Function>(originalMethod: T): T {
return function (order: Order) {
if (!order || !order.item || order.quantity <= 0) {
throw new Error("Invalid order");
}
return originalMethod.apply(this, arguments);
} as T;
}
OrderService.prototype.placeOrder = validationDecorator(OrderService.prototype.placeOrder);
const orderService = new OrderService();
orderService.placeOrder({ item: "Laptop", quantity: 1 });
Security features such as authentication and authorization can be seamlessly integrated using the Decorator pattern. This allows for flexible security implementations that can be easily adjusted as requirements change.
class DocumentService {
viewDocument(docId) {
console.log(`Viewing document ${docId}`);
}
}
function securityDecorator(originalMethod) {
return function (docId) {
if (!this.isAuthenticated) {
throw new Error("User not authenticated");
}
return originalMethod.apply(this, arguments);
};
}
DocumentService.prototype.viewDocument = securityDecorator(DocumentService.prototype.viewDocument);
const documentService = new DocumentService();
documentService.isAuthenticated = true;
documentService.viewDocument(123);
class DocumentService {
isAuthenticated: boolean = false;
viewDocument(docId: number): void {
console.log(`Viewing document ${docId}`);
}
}
function securityDecorator<T extends Function>(originalMethod: T): T {
return function (docId: number) {
if (!this.isAuthenticated) {
throw new Error("User not authenticated");
}
return originalMethod.apply(this, arguments);
} as T;
}
DocumentService.prototype.viewDocument = securityDecorator(DocumentService.prototype.viewDocument);
const documentService = new DocumentService();
documentService.isAuthenticated = true;
documentService.viewDocument(123);
The Decorator pattern offers several advantages over subclassing:
When using multiple decorators, the order of execution can significantly impact the behavior of the decorated object. It’s essential to manage the order in which decorators are applied to ensure the desired outcome.
class ProductService {
getProduct(productId) {
return { id: productId, name: "Product A" };
}
}
function loggingDecorator(originalMethod) {
return function (...args) {
console.log(`Calling ${originalMethod.name} with arguments: ${JSON.stringify(args)}`);
const result = originalMethod.apply(this, args);
console.log(`Result: ${JSON.stringify(result)}`);
return result;
};
}
function cacheDecorator(originalMethod) {
const cache = {};
return function (productId) {
if (cache[productId]) {
console.log("Returning cached result");
return cache[productId];
}
const result = originalMethod.apply(this, arguments);
cache[productId] = result;
return result;
};
}
ProductService.prototype.getProduct = loggingDecorator(cacheDecorator(ProductService.prototype.getProduct));
const productService = new ProductService();
productService.getProduct(1);
productService.getProduct(1);
In this example, the cacheDecorator
is applied before the loggingDecorator
. As a result, the logging will occur every time the method is called, regardless of whether the result is cached.
To deepen your understanding of the Decorator pattern, try modifying the examples above:
To better understand how decorators work in sequence, consider the following flowchart, which represents the execution flow of a method wrapped by multiple decorators:
flowchart TD A[Start] --> B[Call Method] B --> C[Decorator 1] C --> D[Decorator 2] D --> E[Original Method] E --> F[Return from Original Method] F --> G[Return from Decorator 2] G --> H[Return from Decorator 1] H --> I[End]
This flowchart illustrates how the call flows through each decorator before reaching the original method and then returns through each decorator in reverse order.
Remember, mastering design patterns is a journey. The Decorator pattern is just one tool in your toolkit. As you continue to explore and apply these patterns, you’ll find new ways to write more maintainable and scalable code. Keep experimenting, stay curious, and enjoy the journey!