In this article I will explain one Object Oriented Programing Pattern called the "Decorators Pattern" and how we can implement it using typescript to handle multiple-optional behaviors.
Lets imagine that we have an ice-cream shop which offers to our customers the following flavors and toppings:
Flavor | Cost |
Chocolate | 7 |
Pistachio | 6 |
Trululu | 5 |
Toppings | |
Crumbled Waffles | 4 |
JellyBeans | 2 |
Marshmallow | 3 |
Nutella | 2 |
The customer can order a Chocolate ice Cream with any topping. For example: Chocolate ice cream with JellyBeans or Chocolate ice cream with Marshmallow, or Chocolate ice cream with CrumbledWaffles, or even worst Chocolate ice cream with Jellybeans, Marshmallow and CrumbledWaffles... and so on... all the posible combinations that the customer wants...
We can solve it using inheritance and try to represent every posible combinations with a subclass like this:
It is not maintainable and hard to make changes in the system. if the prices change or the shop needs to add more flavors and toppings, we will need to cover all the new posible combinations... (Imagine yourself diving in hundred of classes trying to understand the implementation details or making small changes...)
The decorator Pattern
The pattern definition: "It attaches additional responsibilities to an object dynamically without altering their structure. Decorators provide a flexible alternative to subclassing for extending functionality"
With decorator pattern we can use extensions at runtime rather than at compile time using a form of object composition:
The decorator implement the same abstract class to the component that they are decorating. Also, decorators extend the state of the component and add new methods if it need it.
The concrete component is the object that we can add dynamically new behaviors.
Decorated Ice Creams
Lets apply the decorator pattern to our ice cream shop:
What did we do?
The Component is represented by the IceCream class
The ConcreteComponents are represented by the ice cream flavors (Chocolate, Pistachio, Trululu)
And Decorators are represented by every topping (JellyBeans, Nutella, Marshmallow and Crumbled Waffles)
In the next section we will create the code that we need to get it implemented in typescript.
Project Structure
First run the npm init command to create our package json file:
npm init --yes # this will trigger automatically populated initialization with default values
Now, install the "typescript" and "ts-node" dependencies:
npm install --save-dev ts-node typescript
And create the following project structure:
.
├── main.ts
├── package-lock.json
├── package.json
└── src
├── components
└── decorators
Finally, create the "start" script in the package.json file:
"scripts": {
"start": "ts-node main.ts"
}
Ice Cream Component
export abstract class IceCream {
protected description = "no ice cream description";
public getDescription(): string {
return this.description;
}
public abstract getCost(): number;
}
Chocolate Concrete Component
import { IceCream } from "./ice-cream";
export class Chocolate extends IceCream {
constructor() {
super();
this.description = "Chocolate ice cream";
}
public getCost(): number {
return 7;
}
}
Pistachio Concrete Component
import { IceCream } from "./ice-cream";
export class Pistachio extends IceCream {
constructor() {
super();
this.description = "Pistachio ice cream";
}
public getCost(): number {
return 5;
}
}
Trululu Concrete Component
import { IceCream } from "./ice-cream";
export class Trululu extends IceCream {
constructor() {
super();
this.description = "Trululu ice cream";
}
public getCost(): number {
return 5;
}
}
Topping Decorator
import { IceCream } from "../components/ice-cream";
export abstract class TopppingDecorator extends IceCream {
constructor(protected iceCream: IceCream) {
super();
}
public abstract getDescription(): string;
}
CrumbledWaffles Decorator
import { Toppping } from "./topping";
export class CrumbledWaffles extends Toppping {
public getDescription(): string {
return this.iceCream.getDescription() + ", CrumbledWaffles";
}
public getCost(): number {
return this.iceCream.getCost() + 4;
}
}
Jellybeans Decorator
import { Toppping } from "./topping";
export class JellyBeans extends Toppping {
public getDescription(): string {
return this.iceCream.getDescription() + ", JellyBeans";
}
public getCost(): number {
return this.iceCream.getCost() + 2;
}
}
Marshmallow Decorator
import { Toppping } from "./topping";
export class Marshmallow extends Toppping {
public getDescription(): string {
return this.iceCream.getDescription() + ", Marshmallow";
}
public getCost(): number {
return this.iceCream.getCost() + 4;
}
}
Nutella Decorator
import { Toppping } from "./topping";
export class Nutella extends Toppping {
public getDescription(): string {
return this.iceCream.getDescription() + ", Nutella";
}
public getCost(): number {
return this.iceCream.getCost() + 3;
}
}
Ordering some Ice Creams...
Chocolate Ice cream with marshmallow
//add this code to main.ts
import { Chocolate } from "./src/components/chocolate";
import { IceCream } from "./src/components/ice-cream";
import { Marshmallow } from "./src/decorators/marshmallow";
// making a chocolate ice cream
let chocolateIceCream: IceCream = new Chocolate();
// adding marshmallow
chocolateIceCream = new Marshmallow(chocolateIceCream);
// get your order
console.log("Your order:", chocolateIceCream.getDescription());
console.log("Cost: $", chocolateIceCream.getCost());
//Your order: Chocolate ice cream, Marshmallow
//Cost: $ 9
Execute "npm run start" to get the order details
Pistachio Ice cream with Jelly beans and Nutella toppings
//add this code to main.ts
import { IceCream } from "./src/components/ice-cream";
import { Pistachio } from "./src/components/pistachio";
import { JellyBeans } from "./src/decorators/jelly-beans";
import { Nutella } from "./src/decorators/nutella";
// making a Pistachio ice cream
let pistachioIceCream: IceCream = new Pistachio();
// adding jellybeans
pistachioIceCream = new JellyBeans(pistachioIceCream);
// adding nutella
pistachioIceCream = new Nutella(pistachioIceCream);
// get your order
console.log("Your order:", pistachioIceCream.getDescription());
console.log("Cost: $", pistachioIceCream.getCost());
//Your order: Pistachio ice cream, JellyBeans, Nutella
//Cost: $ 10
Execute "npm run start" to get the order details
Trululu Ice cream with CrumbledWaffles, Jellybeans and Marshmallow
//add this code to main.ts
import { Trululu } from "./src/components/trululu";
import { IceCream } from "./src/components/ice-cream";
import { CrumbledWaffles } from "./src/decorators/crumbled-waffles";
import { JellyBeans } from "./src/decorators/jelly-beans";
import { Marshmallow } from "./src/decorators/marshmallow";
// making a "trululu" ice cream
let trululuIceCream: IceCream = new Trululu();
// adding crumbledWaffles
trululuIceCream = new CrumbledWaffles(trululuIceCream);
// adding jellybeans
trululuIceCream = new JellyBeans(trululuIceCream);
// adding marshmallow
trululuIceCream = new Marshmallow(trululuIceCream);
// more marshmallow... the customer wants it with doble marshmallow
trululuIceCream = new Marshmallow(trululuIceCream);
// get your order
console.log("Your order:", trululuIceCream.getDescription());
console.log("Cost: $", trululuIceCream.getCost());
//Your order: Trululu ice cream, CrumbledWaffles, JellyBeans, Marshmallow, Marshmallow
//Cost: $ 14
Execute "npm run start" to get the order details
By wrapping the IceCream with Toppings you can achieve any combination that the customer wants or the ice cream shop needs.
How its works?
Let's have a look at the topping decorator
export abstract class TopppingDecorator extends IceCream {
constructor(protected iceCream: IceCream) {
super();
}
public abstract getDescription(): string;
}
IceCream is a reference to the Component that each decorator will be wrapping.
getDescription(): every decorator has to reimplement this method to include the ice cream and topping descriptions.
Now, is time to understand the Marshmallow Topping:
export class Marshmallow extends Toppping {
public getDescription(): string {
return this.iceCream.getDescription() + ", Marshmallow";
}
public getCost(): number {
return this.iceCream.getCost() + 4;
}
}
getDescription(): Delegate to the decorated object to get its description and then append "Marshmallow" description to that description.
getCost(): Delegate to the decorated object to compute its cost and then add the cost of "Marshmallow" (+4) to the final cost.
Make an order again
// make a Trululu Object
let trululuIceCream: IceCream = new Trululu();
// wrap it with CrumbledWaffles
trululuIceCream = new CrumbledWaffles(trululuIceCream);
// wrap it with Jellybeans
trululuIceCream = new JellyBeans(trululuIceCream);
// wrap it with Marshmallow
trululuIceCream = new Marshmallow(trululuIceCream);
// get your order
console.log("Your order:", trululuIceCream.getDescription());
console.log("Cost: $", trululuIceCream.getCost());
We can visualize the wrap order of the getDescription and getCost method as an onion diagram:
Conclusions
By using this approach we apply one of the S.O.L.I.D principles: Open/Closed (OCP) which allows every decorator implement their own behavior without modifying the existent code.
You can have a look at oas-to-joi library which is a real example of how you can implement the decorator pattern in another context.
Also, here is the Github repository of the this project.
See you in the next article