The Visitor Pattern is a behavioral design pattern that allows you to add further operations to objects without modifying their classes. Instead of embedding the logic within the objects themselves, you define a separate visitor that performs actions on them. This pattern is especially useful when dealing with groups of objects that may need different types of operations or behavior added over time, all without altering the underlying object structures.
In this tutorial, we will explore the Visitor Pattern through a coffee shop example. Imagine you run a coffee shop offering different types of coffee, and over time, you need to apply new operations like calculating discounts or measuring calories. Instead of hardcoding these operations inside each coffee type (which would make the code less flexible), you can define visitors that handle these operations externally.
By the end of this tutorial, you'll see how to:
- Implement the Visitor Pattern using TypeScript.
- Apply visitors to objects like
Espresso, Latte, and Cappuccinoto perform operations such as discount calculation and calorie counting. - Extend your codebase with new operations while keeping your coffee objects clean and easy to maintain.
Let's dive in and see how the Visitor Pattern helps us create a flexible and extensible coffee shop system!
Start by defining the Coffee interface and different types of coffee.
interface Coffee {
accept(visitor: CoffeeVisitor): void;
}
class Espresso implements Coffee {
accept(visitor: CoffeeVisitor): void {
visitor.visitEspresso(this);
}
cost(): number {
return 3.0;
}
calories(): number {
return 50;
}
}
class Latte implements Coffee {
accept(visitor: CoffeeVisitor): void {
visitor.visitLatte(this);
}
cost(): number {
return 4.5;
}
calories(): number {
return 150;
}
}
class Cappuccino implements Coffee {
accept(visitor: CoffeeVisitor): void {
visitor.visitCappuccino(this);
}
cost(): number {
return 4.0;
}
calories(): number {
return 100;
}
}Here, we define a Coffee interface with an accept method that accepts a visitor. Each coffee type (e.g., Espresso, Latte, Cappuccino) implements this method and provides other properties, like cost and calories.
Next, define the CoffeeVisitor interface and different visitors that implement this interface to perform various operations.
interface CoffeeVisitor {
visitEspresso(espresso: Espresso): void;
visitLatte(latte: Latte): void;
visitCappuccino(cappuccino: Cappuccino): void;
}
class DiscountVisitor implements CoffeeVisitor {
visitEspresso(espresso: Espresso): void {
console.log(`Espresso cost after discount: ${espresso.cost() * 0.9}`);
}
visitLatte(latte: Latte): void {
console.log(`Latte cost after discount: ${latte.cost() * 0.85}`);
}
visitCappuccino(cappuccino: Cappuccino): void {
console.log(`Cappuccino cost after discount: ${cappuccino.cost() * 0.8}`);
}
}
class CalorieVisitor implements CoffeeVisitor {
visitEspresso(espresso: Espresso): void {
console.log(`Espresso has ${espresso.calories()} calories`);
}
visitLatte(latte: Latte): void {
console.log(`Latte has ${latte.calories()} calories`);
}
visitCappuccino(cappuccino: Cappuccino): void {
console.log(`Cappuccino has ${cappuccino.calories()} calories`);
}
}Here, the CoffeeVisitor interface defines visit methods for each coffee type. The DiscountVisitor and CalorieVisitor classes implement the visitor interface and apply specific operations like applying discounts and calculating calories.
Now, you can create instances of coffee and visitors, and apply the visitors to the coffee objects.
const espresso = new Espresso();
const latte = new Latte();
const cappuccino = new Cappuccino();
const discountVisitor = new DiscountVisitor();
const calorieVisitor = new CalorieVisitor();
// Apply discount visitor
espresso.accept(discountVisitor); // Espresso cost after discount: 2.7
latte.accept(discountVisitor); // Latte cost after discount: 3.825
cappuccino.accept(discountVisitor); // Cappuccino cost after discount: 3.2
// Apply calorie visitor
espresso.accept(calorieVisitor); // Espresso has 50 calories
latte.accept(calorieVisitor); // Latte has 150 calories
cappuccino.accept(calorieVisitor); // Cappuccino has 100 caloriesHere is a list of tests that should be written to ensure the functionality of the Visitor Pattern implementation using MochaJS and TypeScript:
-
Test Espresso with DiscountVisitor:
- Ensure
DiscountVisitorapplies the correct discount to anEspresso.
- Ensure
-
Test Latte with DiscountVisitor:
- Ensure
DiscountVisitorapplies the correct discount to aLatte.
- Ensure
-
Test Cappuccino with DiscountVisitor:
- Ensure
DiscountVisitorapplies the correct discount to aCappuccino.
- Ensure
-
Test Espresso with CalorieVisitor:
- Ensure
CalorieVisitorcorrectly calculates the calories for anEspresso.
- Ensure
-
Test Latte with CalorieVisitor:
- Ensure
CalorieVisitorcorrectly calculates the calories for aLatte.
- Ensure
-
Test Cappuccino with CalorieVisitor:
- Ensure
CalorieVisitorcorrectly calculates the calories for aCappuccino.
- Ensure
-
Test Multiple Visitors on Coffee:
- Ensure that both
DiscountVisitorandCalorieVisitorcan be applied in sequence to each coffee type without issues.
- Ensure that both
The Visitor Pattern separates functionality (like discounts or calorie calculations) from the coffee classes themselves.
- Each
Coffeeclass accepts a visitor, and the visitor performs operations based on the type of coffee. - This pattern makes it easy to extend the system with new operations (like adding a new visitor) without modifying the coffee classes.
- This approach keeps your system flexible and maintainable, especially when you need to add new functionalities.