Decorator Pattern in TypeScript — ✨ Structural Design Pattern #3
🙋🏻♀️ What is the Decorator Pattern?
The Decorator Pattern is a structural design approach that provides a way to extend the functionality of an object dynamically without subclassing or modifying its underlying structure.
Decorator
objects enhance the decorated object’s behavior by adding additional logic by simply wrapping the object, so it is sometimes called Wrap Pattern as well. Of course, the decorator
needs to conform to the same interface as the object it’s wrapping.
The Decorator Pattern follows the principle of composition over inheritance
, promoting flexibility and code reuse. Instead of creating multiple subclasses with different combinations, decorators
can be stacked or removed at runtime, allowing for dynamic modification of an object’s functionality.
✨ Simple TypeScript Example
Let’s say you start a small coffee business. Here, you have a component interface Coffee
that defines the common operations for coffee objects, such as getCost()
and getDescription()
.
interface Coffee {
getCost(): number
getDescription(): string
}
SimpleCoffee
is a concrete class that implements the Coffee
interface. It represents the base coffee without any additional customization options.
class SimpleCoffee implements Coffee {
getCost(): number {
return 4_100
}
getDescription(): string {
return 'Single Origin Coffee'
}
}
// Client Code
const coffee: Coffee = new SimpleCoffee()
console.log(coffee.getDescription()) // Single Origin Coffee
console.log(coffee.getCost()) // 4100
Now, your customer is interested in having additional options such as adding milk or syrup, rather than being limited to simple coffee.
You prepare an abstract decorator class CoffeeDecorator
. This has a reference to the Coffee
and delegates getCost()
and getDescription()
to the decorated coffee.
// Decorator class
class CoffeeDecorator implements Coffee {
protected decoratedCoffee: Coffee
constructor(decoratedCoffee: Coffee) {
this.decoratedCoffee = decoratedCoffee
}
getCost(): number {
return this.decoratedCoffee.getCost() // delegation
}
getDescription(): string {
return this.decoratedCoffee.getDescription() // delegation
}
}
The Milk
and HazelnutSyrup
classes are concrete decorators that extend the CoffeeDecorator
class. They add specific functionalities to the decorated coffee by overriding the getCost()
and getDescription()
.
// Concrete decorators
class Milk extends CoffeeDecorator {
constructor(decoratedCoffee: Coffee) {
super(decoratedCoffee)
}
getCost(): number {
return super.getCost() + 500
}
getDescription(): string {
return super.getDescription() + ', Milk'
}
}
class HazelnutSyrup extends CoffeeDecorator {
constructor(decoratedCoffee: Coffee) {
super(decoratedCoffee)
}
getCost(): number {
return super.getCost() + 300
}
getDescription(): string {
return super.getDescription() + ', Hazelnut Syrup'
}
}
In the client code, the decorators
are applied in a chain, and each decorator modifies the cost and description of the decorated coffee.
// Client Code
const coffee: Coffee = new SimpleCoffee()
const cafeLatte: Coffee = new Milk(coffee)
const hazelnutLatte: Coffee = new HazelnutSyrup(cafeLatte)
console.log(hazelnutLatte.getDescription()) // Single Origin Coffee, Milk, Hazelnut Syrup
console.log(hazelnutLatte.getCost()) // 4900
🧑🏻💻 Use it or Avoid it
When to use it
The Decorator Pattern is useful if you need the ability to add or remove behaviors dynamically at runtime, or if you need fine-grained control over its functionalities.
Also, it’s a good strategy to promote modularity. You can create a set of decorators that can be combined in various ways. This modular approach makes it easier to maintain and extend the codebase.
When to avoid it
It’s noteworthy that decorators
might result in a deep nesting of wrapped objects, so it may negatively impact performance and readability. Therefore, if the functionality you need to add is quite simple, a simpler approach like subclassing or direct modification might be more appropriate.