Photo by Martin Sanchez on Unsplash
Beyond Basics: Designing Flexible and Extensible TypeScript Applications with Decorator Design Pattern
The Decorator design pattern is a structural pattern that allows behavior to be added to an individual object at runtime without affecting the behavior of other objects from the same class. This blog post will explore how to use the Decorator pattern in Typescript and its benefits and drawbacks.
Note about the Decorators feature in Typescript
Implementing the Decorator
Let's consider a simple example where we have a class called Shape
with a method called draw
. We want to add a border to the shape without changing the original code. We can achieve this using the Decorator pattern.
Let's start with an IShape
interface and the Shape
class that implements it.
interface IShape {
draw(): void;
}
class Shape implements IShape {
public draw(): void {
console.log('Drawing a shape');
}
}
Now, let's create a ShapeWithBorder
class that will decorate the Shape
class and will add a border to the shape. As the first step, the ShapeWithBorder
class will implement the IShape
interface.
class ShapeWithBorder implements IShape {
private shape: IShape;
constructor(shape: IShape) {
this.shape = shape;
}
public draw(): void {
console.log('Adding a border to the shape');
this.shape.draw();
}
}
As the second step, the ShapeWithBorder
class accepts an object implementing the IShape
interface in its constructor. This way we can provide any IShape
compliant object to it, and the class will decorate this object with a border. Notice that because ShapeWithBorder
implements IShape
can also act as a decoration receiver. This way, we can add multiple decorators on top of each other, which we will explore in the next section.
Now, we can create an instance of the ShapeWithBorder
class and pass an instance of the Shape
class to its constructor. Finally, we call the draw
method on the ShapeWithBorder
instance, which will add a border to the shape and draw it.
const shape = new ShapeWithBorder(new Shape());
shape.draw();
You can find this example in this CodeSandbox.
Beyond Basics
Imagine you're building a system to generate reports for a finance company. You have a Report
class that generates a basic report, but you want to add additional features to the report based on the user's preferences. For example, some users may want to see charts and graphs, while others may want to see tables of data. Instead of creating separate classes for each report type or using inheritance, you can use the Decorator pattern to add the required features at runtime.
First, let's define an interface for our Report
class:
interface IReport {
getData(): Record<number, number>;
generate(): void;
}
Next, let's create a basic Report
class that implements the interface:
class BasicReport implements IReport {
constructor(private _data: Record<number, number>) {}
public getData(): Record<number, number> {
return this._data;
}
public generate(): void {
console.log('Generating basic report...');
}
}
Now, let's create two decorator classes, one for charts and one for tables:
class ReportWithCharts implements IReport {
private report: IReport;
constructor(report: IReport) {
this.report = report;
}
public getData(): Record<number, number> {
return this.report.getData();
}
public generate(): void {
this.report.generate();
console.log('Adding charts to report...');
const data = this.report.getData();
// Use data to generate charts...
}
}
class ReportWithTables implements IReport {
private report: IReport;
constructor(report: IReport) {
this.report = report;
}
public getData(): Record<number, number> {
return this.report.getData();
}
public generate(): void {
this.report.generate();
console.log('Adding tables to report...');
const data = this.report.getData();
// Use data to generate the tables...
}
}
Finally, we can create an instance of the BasicReport
class and pass it through the decorator classes to add the required features:
const now = Date.now()
const sampleData = {
[now-2]: 123,
[now-1]: 456,
[now]: 789,
}
const report: IReport = new ReportWithCharts(
new ReportWithTables(new BasicReport(sampleData))
);
report.generate();
You can find the full example in this CodeSandbox.
This will generate a report with charts and tables, but you can easily modify it to generate a report with only charts or only tables by removing the corresponding decorator classes.
We can also create new decorator classes for additional features without modifying the existing code, which makes it easier to maintain and extend the system over time.
Benefits
Flexibility: The pattern provides a flexible way to add behavior to an object at runtime. It allows you to add or remove behavior without changing the original code, making it easier to maintain.
Reusability: Decorators can be reused on different objects to add the same behavior, making the code more efficient and reducing the need to write duplicate code.
Separation of concerns: Separating an advanced or optional behavior of an object from its core functionality.
Drawbacks
Complexity: Using the Decorator pattern can make the code more complex, especially when dealing with multiple decorators.
Performance: Adding behavior at runtime can affect the performance of your code. It all depends on how the decorators and the core class are implemented. It is important to avoid the N+1 problem, which can be created by executing the core class for each decorator that encapsulates it within the single chain.
In conclusion, the Decorator pattern is a valuable design pattern that can be used in Typescript to add behavior to an object at runtime without affecting other objects of the same class. It provides flexibility, reusability, and separation of concerns. However, it can also make the code more complex and affect performance. By following the examples outlined in this blog post, you can effectively implement the Decorator pattern in your Typescript code.