Decorator Design Pattern in TypeScript (Part 2): Structure, Consequences & Implementation

Hooman Momtaheni
5 min readJun 9, 2024

--

In Part 1 we discussed about motivation & applicability structure of decorator design pattern. Now lets take a look at its structure, consequences and implementation.

Structure

Credit: Design Patterns: Elements of Reusable Object-Oriented Software

Participants

Component (VisualComponent)

- defines the interface for objects that can have responsibilities added to them dynamically.

ConcreteComponent (TextView)

- defines an object to which additional responsibilities can be attached.

Decorator

- maintains a reference to a Component object and defines an interface that conforms to Component’s interface.

ConcreteDecorator (BorderDecorator, ScrollDecorator)

  • adds responsibilities to the component.

Collaborations

Decorator forwards requests to its Component object. It may optionally perform additional operations before and after forwarding the request.

Consequences

The Decorator pattern has at least two key benefits and two liabilities:

1. More flexibility than static inheritance. The Decorator pattern provides a more flexible way to add responsibilities to objects than can be had with static (multiple) inheritance. With decorators, responsibilities can be added and removed at run-time simply by attaching and detaching them (in our example using TextView or scrollableTextView in runtime). In contrast, inheritance requires creating a new class for each additional responsibility (e.g., BorderedScrollableTextView, BorderedTextView). This gives rise to many classes and increases the complexity of a system. Furthermore, providing different Decorator classes for a specific Component class lets you mix and match responsibilities.
Decorators also make it easy to add a property twice. For example, to give a TextView a double border, simply attach two BorderDecorators. Inheriting from a Border class twice is error-prone at best.

2. Avoid having complex classes with many features at the top of your hierarchy. Decorator offers a pay as-you-go approach to adding responsibilities. Instead of trying to support all foreseeable features in a complex, customizable class, you can define a simple class and add functionality incrementally with Decorator objects. Functionality can be composed from simple pieces. As a result, an application needn’t pay for features it doesn’t use. It’s also easy to define new kinds of Decorators independently from the classes of objects they extend, even for unforeseen extensions. Extending a complex class tends to expose details unrelated to the responsibilities you’re adding.

3. A decorator and its component aren’t identical. A decorator acts as a transparent enclosure. But from an object identity point of view, a decorated component is not identical to the component itself (TextView is not same as scrollableTextView). Hence you shouldn’t rely on object identity when you use decorators.

4. Lots of little objects. A design that uses Decorator often results in systems composed of lots of little objects that all look alike. The objects differ only in the way they are interconnected, not in their class or in the value of their variables. Although these systems are easy to customize by those who understand them, they can be hard to learn and debug.

Implementation

1.Interface conformance. A decorator object’s interface must conform to the interface of the component it decorates. ConcreteDecorator classes must therefore inherit from a common class.

In our example:

interface VisualComponent {
draw(): void;
}
class TextView implements VisualComponent {
draw(): void {
console.log("Drawing TextView");
}
}
class Decorator implements VisualComponent {
protected component: VisualComponent;

constructor(component: VisualComponent) {
this.component = component;
}

draw(): void {
this.component.draw();
}
}

2. Omitting the abstract Decorator class. There’s no need to define an abstract Decorator class when you only need to add one responsibility. That’s often the case when you’re dealing with an existing class hierarchy or add a single responsibility. It avoids the extra layer of abstraction and reduces complexity.

3. Keeping Component classes lightweight. To ensure a conforming interface, components and decorators must descend from a common Component class. It’s important to keep this common class lightweight; that is, it should focus on defining an interface, not on storing data (instance variables). The definition of the data representation should be deferred to subclasses; otherwise, the complexity of the Component class might make the decorators too heavyweight to use in quantity. Putting a lot of functionality into Component also increases the probability that concrete subclasses will pay for features they don’t need. For example:

Good design (Lightweight Component):

interface VisualComponent {
draw(): void;
}

Bad design (Heavyweight Component):

interface VisualComponent {
draw(): void;
setBackgroundColor(color: string): void; // Additional functionality
setBorder(width: number): void; // Additional functionality
}

4. Changing the skin of an object versus changing its guts (Decorator Pattern VS Strategy Pattern). We can think of a decorator as a skin over an object that changes its behavior. An alternative is to change the object’s guts. The Strategy pattern is a good example of a pattern for changing the guts (internal organ). Strategies are a better choice in situations where the Component class is intrinsically heavyweight, thereby making the Decorator pattern too costly to apply. In the Strategy pattern, the component forwards some of its behavior to a separate strategy object. The Strategy pattern lets us alter or extend the component’s functionality by replacing the strategy object.
For example, we can support different border styles by having the component defer border-drawing to a separate Border object. The Border object is a Strategy object that encapsulates a border-drawing strategy. By extending the number of strategies from just one to an open-ended list, we achieve the same effect as nesting decorators recursively.
Since the Decorator pattern only changes a component from the outside, the component doesn’t have to know anything about its decorators; that is, the decorators are transparent to the component:

With strategies, the component itself knows about possible extensions. So, it has to reference and maintain the corresponding strategies:

The Strategy-based approach might require modifying the component to accommodate new extensions. On the other hand, a strategy can have its own specialized interface, whereas a decorator’s interface must conform to the component’s. A strategy for rendering a border, for example, need only define the interface for rendering a border (drawBorder, getWidth, etc.), which means that the strategy can be lightweight even if the Component class is heavyweight.

Related Patterns

Adapter: A decorator is different from an adapter in that a decorator only changes an object’s responsibilities, not its interface; an adapter will give an object a completely new interface.

Composite: A decorator can be viewed as a degenerate composite with only one component. However, a decorator adds additional responsibilities — it isn’t intended for object aggregation.

Strategy: A decorator lets you change the skin of an object; a strategy lets you change the guts. These are two alternative ways of changing an object.

--

--

Hooman Momtaheni

Full Stack Web Application Developer | Admire foundations and structures | Helping companies have reliable web apps and motivated software development team