Lesson 3
Exploring the Decorator Pattern in JavaScript
Exploring the Decorator Pattern

Welcome! Having learned about various design patterns, you're well on your way to mastering structural design patterns using JavaScript. In this lesson, we'll explore the Decorator Pattern, an important structural pattern that allows us to add new functionalities to objects dynamically and transparently. This is particularly useful when you want to enhance the behavior of objects without modifying their core code.

The Decorator Pattern enables you to wrap an object with additional behavior in a flexible and reusable way. You'll learn how to use this pattern to extend the functionality of objects in a clean and maintainable manner. For example, let's consider a simple coffee ordering system. By the end of this lesson, you will be able to create a basic Coffee class and implement decorators like MilkDecorator and SugarDecorator to add features to the basic coffee object.

Understanding the Decorator Pattern

To understand the Decorator Pattern, let's think about a coffee shop where you can customize your coffee with various add-ons.

Consider a scenario in a coffee shop where you start with a simple cup of coffee and then enhance it with additional ingredients like milk, sugar, or whipped cream.

  • Simple Coffee (Core Component): The basic coffee serves as the core component. It has fundamental properties, such as description and cost.
  • Milk (Decorator): Adding milk to the coffee decorates it with additional features like an extra description ("Milk") and additional cost.
  • Sugar (Decorator): Similarly, adding sugar will decorate the coffee with a sugar description and added cost.
  • Multiple Decorations: You can layer decorators. For instance, you can first add milk and then add sugar to the already milk-decorated coffee. Each decorator wraps the core component or another decorator, enhancing its behavior.

In this structure, each decorator extends the functionality of the original component dynamically. You start with a basic coffee and, by wrapping it with different decorators, you can create complex coffee orders. For example, if you have a simple coffee object and want to create a coffee with milk and sugar, you don't need to create a new class for every combination of coffee. Instead, you can layer existing decorators:

JavaScript
1let simpleCoffee = new SimpleCoffee(); 2let milkCoffee = new MilkDecorator(simpleCoffee); 3let milkAndSugarCoffee = new SugarDecorator(milkCoffee);

This allows you to dynamically add or remove functionality to objects without altering their structure, leading to more modular and maintainable code.

Step 1: Creating the Coffee Class

We start by defining a Coffee class. JavaScript does not have built-in abstract class support, so we'll simulate it using conventions where unimplemented methods throw errors. The SimpleCoffee class provides the implementation for the cost method.

JavaScript
1class Coffee { 2 getDescription() { 3 return "Coffee"; 4 } 5 6 cost() { 7 throw new Error("This method should be overridden!"); 8 } 9} 10 11class SimpleCoffee extends Coffee { 12 cost() { 13 return 2.0; 14 } 15} 16 17// Verify SimpleCoffee 18let myCoffee = new SimpleCoffee(); 19console.log(`Description: ${myCoffee.getDescription()}, Cost: ${myCoffee.cost()}`); 20// Description: Coffee, Cost: 2.0
Step 2: Creating the CoffeeDecorator Base Class

Next, we create a CoffeeDecorator class that also extends Coffee. It takes an instance of Coffee and delegates calls to getDescription and cost methods to the wrapped instance.

JavaScript
1class CoffeeDecorator extends Coffee { 2 constructor(decoratedCoffee) { 3 super(); 4 this.decoratedCoffee = decoratedCoffee; 5 } 6 7 getDescription() { 8 return this.decoratedCoffee.getDescription(); 9 } 10 11 cost() { 12 return this.decoratedCoffee.cost(); 13 } 14} 15 16// Usage example for CoffeeDecorator 17 18let simpleCoffee = new SimpleCoffee(); 19let decoratedCoffee = new CoffeeDecorator(simpleCoffee); 20 21console.log(`Description: ${decoratedCoffee.getDescription()}, Cost: ${decoratedCoffee.cost()}`); 22// Description: Coffee, Cost: 2.0
Step 3: Adding MilkDecorator

Following this, we extend CoffeeDecorator to create a MilkDecorator class. It overrides the getDescription and cost methods to add the description and cost of milk to the original coffee.

JavaScript
1class MilkDecorator extends CoffeeDecorator { 2 getDescription() { 3 return `${this.decoratedCoffee.getDescription()}, Milk`; 4 } 5 6 cost() { 7 return this.decoratedCoffee.cost() + 0.5; 8 } 9} 10 11// Add MilkDecorator 12let myCoffeeWithMilk = new MilkDecorator(myCoffee); 13console.log(`Description: ${myCoffeeWithMilk.getDescription()}, Cost: ${myCoffeeWithMilk.cost()}`); 14// Description: Coffee, Milk, Cost: 2.5
Step 4: Adding SugarDecorator

Similarly, we create a SugarDecorator class by extending CoffeeDecorator. This decorator adds a sugar-related description and cost.

JavaScript
1class SugarDecorator extends CoffeeDecorator { 2 getDescription() { 3 return `${this.decoratedCoffee.getDescription()}, Sugar`; 4 } 5 6 cost() { 7 return this.decoratedCoffee.cost() + 0.3; 8 } 9} 10 11// Add SugarDecorator 12let myCoffeeWithMilkAndSugar = new SugarDecorator(myCoffeeWithMilk); 13console.log(`Description: ${myCoffeeWithMilkAndSugar.getDescription()}, Cost: ${myCoffeeWithMilkAndSugar.cost()}`); 14// Description: Coffee, Milk, Sugar, Cost: 2.8
Chapter: Full Code Example

Here is the complete code combining all the sections:

JavaScript
1class Coffee { 2 getDescription() { 3 return "Coffee"; 4 } 5 6 cost() { 7 throw new Error("This method should be overridden!"); 8 } 9} 10 11class SimpleCoffee extends Coffee { 12 cost() { 13 return 2.0; 14 } 15} 16 17class CoffeeDecorator extends Coffee { 18 constructor(decoratedCoffee) { 19 super(); 20 this.decoratedCoffee = decoratedCoffee; 21 } 22 23 getDescription() { 24 return this.decoratedCoffee.getDescription(); 25 } 26 27 cost() { 28 return this.decoratedCoffee.cost(); 29 } 30} 31 32class MilkDecorator extends CoffeeDecorator { 33 getDescription() { 34 return `${this.decoratedCoffee.getDescription()}, Milk`; 35 } 36 37 cost() { 38 return this.decoratedCoffee.cost() + 0.5; 39 } 40} 41 42class SugarDecorator extends CoffeeDecorator { 43 getDescription() { 44 return `${this.decoratedCoffee.getDescription()}, Sugar`; 45 } 46 47 cost() { 48 return this.decoratedCoffee.cost() + 0.3; 49 } 50} 51 52// Example usage 53let myCoffee = new SimpleCoffee(); 54console.log(`Description: ${myCoffee.getDescription()}, Cost: ${myCoffee.cost()}`); 55 56let myCoffeeWithMilk = new MilkDecorator(myCoffee); 57console.log(`Description: ${myCoffeeWithMilk.getDescription()}, Cost: ${myCoffeeWithMilk.cost()}`); 58 59let myCoffeeWithMilkAndSugar = new SugarDecorator(myCoffeeWithMilk); 60console.log(`Description: ${myCoffeeWithMilkAndSugar.getDescription()}, Cost: ${myCoffeeWithMilkAndSugar.cost()}`); 61 62// Outputs: 63// Description: Coffee, Cost: 2.0 64// Description: Coffee, Milk, Cost: 2.5 65// Description: Coffee, Milk, Sugar, Cost: 2.8
Conclusion

Mastering the Decorator Pattern is important because it allows you to extend the functionality of objects at runtime without altering their structure. This leads to more flexible and maintainable code compared to using subclassing for every new feature. In our coffee example, you can easily add new features like milk and sugar by layering decorators, which can be combined in various ways. Whether you need to add functionality to user interface components, logging systems, or graphical rendering engines, the Decorator Pattern provides a powerful and elegant solution. Ready to see how this unfolds? Let's get started with the practice section, where you'll implement and extend the Decorator Pattern step-by-step.

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.