Decorator Pattern: Let's have an ice cream!

Max Martínez CartagenaMax Martínez Cartagena
Jun 03, 2024·
7 min read

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:

FlavorCost
Chocolate7
Pistachio6
Trululu5
Toppings
Crumbled Waffles4
JellyBeans2
Marshmallow3
Nutella2

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

14
Subscribe to my newsletter

Read articles from Max Martínez Cartagena directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Max Martínez Cartagena
Max Martínez Cartagena

I'm an enthusiastic Chilean software engineer in New Zeland. I mostly focus on the back-end of the systems. This is my site, Señor Developer, where I share my knowledge and experience.