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
The Decorator design pattern and the Decorators feature in TypeScript are related concepts, but they have some key differences. The Decorators is a TypeScript feature that allows you to annotate classes and class members with decorator functions. These decorators are functions that can modify the behavior of a class or its members. One key difference between them is that the Decorator pattern is a well-established software design pattern that can be used in any programming language, while Decorators is a specific (experimental) feature of 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

  1. 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.

  2. 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.

  3. Separation of concerns: Separating an advanced or optional behavior of an object from its core functionality.

Drawbacks

  1. Complexity: Using the Decorator pattern can make the code more complex, especially when dealing with multiple decorators.

  2. 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.