How to Implement Design Patterns in TypeScript: Decorator
What is the Decorator Design Pattern?
The Decorator pattern is a structural design pattern that allows behavior to be added to individual objects dynamically, at runtime, without affecting other instances of the same class. It is a way to extend the functionality of an object in a flexible and scalable manner. The pattern is one of the Gang of Four design patterns, introduced by the book “Design Patterns: Elements of Reusable Object-Oriented Software.”
Intent of the Decorator pattern:
- Attach additional responsibilities to an object dynamically.
- Provide a flexible alternative to subclassing for extending functionality.
- Allow the addition of new behaviors to individual objects without affecting other objects of the same class.
The key components of the Decorator pattern are:
Component: This is the interface or abstract class representing the object’s interface that will have responsibilities added to it. It defines the common operations that concrete components and decorators must implement.
Concrete Component: This is the class that implements the Component interface. It defines the basic behavior to which additional responsibilities can be added.
Decorator: This is the abstract class that also implements the Component interface but maintains a reference to a Component object. This reference allows decorators to wrap or “decorate” concrete components.
Concrete Decorator: These are the classes that extend the Decorator and add additional responsibilities to the components.
The Decorator pattern offers several advantages that make it a valuable tool in software design and development. Some of the main advantages of the Decorator pattern are:
Flexible Extension: The Decorator pattern allows you to add new functionality or responsibilities to objects dynamically at runtime. This flexibility comes from the ability to wrap objects with decorators and combine them in various ways. It avoids the need for creating numerous subclasses to achieve different combinations of features, making the codebase more maintainable and extensible.
Open-Closed Principle: The Decorator pattern adheres to the Open-Closed Principle, which states that classes should be open for extension but closed for modification. By using decorators, you can extend the behavior of objects without modifying their existing code. This promotes code reusability and minimizes the risk of introducing bugs while making changes.
Single Responsibility Principle: The pattern allows you to divide the responsibilities among multiple small classes (decorators) rather than having a single monolithic class with all functionalities. Each decorator has a specific responsibility, making the codebase more organized and easier to manage.
Combination of Behaviors: Decorators can be stacked and combined in various ways, providing fine-grained control over an object’s behavior. This allows you to create different configurations of objects at runtime based on specific requirements. It promotes the creation of tailored and composite objects without the need for a class explosion.
Separation of Concerns: The Decorator pattern separates the concerns of adding functionality and the core implementation of the object. The core component and its decorators can focus on their specific tasks without tightly coupling with each other.
Easy to Maintain: As compared to traditional inheritance-based approaches, the Decorator pattern makes it easier to maintain and extend code. Adding new features or modifying existing ones can be done by creating new decorators or changing the order of decorators without affecting other parts of the code.
Complexity Management: Instead of having a single class with a multitude of optional features and combinations, the Decorator pattern allows you to manage complexity by breaking down responsibilities into smaller, manageable classes. This results in a more modular and maintainable design.
Promotes Composition over Inheritance: The Decorator pattern emphasizes the use of object composition over inheritance, which is often considered a better approach to achieve flexibility and avoid the limitations of deep class hierarchies.
How to Implement Decorator?
Let’s consider a practical example of how the Decorator pattern can be applied in a TypeScript project. We’ll implement a simple online shopping system where we have different types of products (e.g., electronics, books, clothing) and offer optional gift wrapping and discounts for certain products.
First, we define the basic Product
interface that represents the core functionality of a product:
interface Product {
name: string;
price: number;
getDescription(): string;
getPrice(): number;
}
Next, we’ll create a concrete implementation of the Product
interface for an electronic item:
class ElectronicProduct implements Product {
constructor(public name: string, public price: number) {}
getDescription(): string {
return this.name;
}
getPrice(): number {
return this.price;
}
}
Now, let’s create a decorator for gift wrapping:
class GiftWrappingDecorator implements Product {
constructor(private product: Product) {}
getDescription(): string {
return this.product.getDescription() + " (Gift Wrapped)";
}
getPrice(): number {
return this.product.getPrice() + 5; // Assuming gift wrapping costs $5
}
}
Next, let’s create a decorator for applying discounts on certain products:
class DiscountDecorator implements Product {
constructor(private product: Product, private discountPercentage: number) {}
getDescription(): string {
return this.product.getDescription() + ` (Discount ${this.discountPercentage}%)`;
}
getPrice(): number {
const discount = (this.discountPercentage / 100) * this.product.getPrice();
return this.product.getPrice() - discount;
}
}
Now, let’s see how we can use these decorators in our project:
const electronicProduct: Product = new ElectronicProduct("Smartphone", 500);
const giftWrappedProduct: Product = new GiftWrappingDecorator(electronicProduct);
const discountedAndGiftWrappedProduct: Product = new DiscountDecorator(
new GiftWrappingDecorator(electronicProduct),
10 // 10% discount
);
console.log(electronicProduct.getDescription()); // Output: "Smartphone"
console.log(electronicProduct.getPrice()); // Output: 500
console.log(giftWrappedProduct.getDescription()); // Output: "Smartphone (Gift Wrapped)"
console.log(giftWrappedProduct.getPrice()); // Output: 505
console.log(discountedAndGiftWrappedProduct.getDescription()); // Output: "Smartphone (Gift Wrapped) (Discount 10%)"
console.log(discountedAndGiftWrappedProduct.getPrice()); // Output: 454.5 (500 + $5 gift wrapping - 10% discount)
In this example, we have implemented the Decorator pattern to add gift wrapping and discount functionalities to our Product
objects. The key advantage here is that we can combine different decorators to create various configurations of products without modifying their core implementation. This makes the code more flexible and maintainable, allowing us to add or remove features easily without affecting other parts of the system.
Overall, the Decorator pattern promotes a more flexible and modular design by enabling the dynamic addition of features to objects at runtime.