Strategy Design Pattern in Typescript

Introduction
The Strategy Design Pattern is a behavioural design pattern that allows us to define a group of algorithms, encapsulate each one, and make them interchangeable at runtime . This pattern allows you to change the algorithm without changing the parts of the code that use it. In the context of Angular and Typescript, understanding and implementing this pattern can significantly enhance code flexibility and maintainability.
When to use it ?
Let's take an example , you want to calculate the estimated travel time required for different modes of transport - walking , train and car .
Without a proper design pattern, you might end up with a lot of conditional logic in your estimated time function , something like this :
calculateETA(totalDistance: number, method: TravelMethod): number
{
if (totalDistance < 0)
throw new Error("Distance cannot be negative");
if (method === TravelMethod.Walking) {
return totalDistance / this.walkingSpeed;
} else if (method === TravelMethod.Car) {
return totalDistance / this.carSpeed;
} else if (method === TravelMethod.Train) {
return totalDistance / this.trainSpeed;
} else if (method === TravelMethod.Bicycle) { // New condition
return totalDistance / this.bicycleSpeed;
} else {
throw new Error("Invalid travel method");
}
}
Without Strategy Pattern
This approach can make the code harder to maintain and extend. For example, if you want to add a new mode of transport like a bicycle, you'd have to modify the existing function and add a new speed variable in the class. This breaks the Open-Closed Principle, which says software should be open to extension but closed to modification.
The Strategy Design Pattern solves this by putting the logic for each transport mode into its own class, all using a shared interface. This way, you can easily add new transport types without changing any existing code.
Understanding the pattern

The Strategy Design Pattern consists of three main components:
- Strategy: This is the interface that defines the contract for all concrete strategies. It declares a method that all concrete strategies must implement.
- Concrete Strategies: These are classes that implement the Strategy interface. Each concrete strategy provides a specific implementation of the algorithm or behavior.
- Context: This is the class that uses a strategy. It contains a reference to a strategy object and delegates some of its work to it.
Using our mode of transport example:
- Strategy:
TravelStrategy
interface withcalculateETA(totalDistance : number)
method. - Concrete Strategies:
WalkingStrategy
,CarStrategy
,TrainStrategy
classes implementingTravelStrategy
interface. - Context:
TravelPlanner
class that uses aTravelStrategy
to process calculation of ETA .
To make it relatable, think of it like choosing a mode of transportation: walking , driving or by train . The context (you) wants to get somewhere, and the strategy is how you choose to travel. You don't need to know the details of each mode; you just use it to calculate your ETA .
Example in Typescript
Let's redesign our old calculateETA based on strategy pattern . We have concrete implementation of TravelStrategy for our walking , train and car mode of transport
We will have a TravelPlanner who would help us calculateETA based on our strategy
Let define the TravelStrategy interface
// Strategy Interface
interface TravelStrategy {
calculateETA(totalDistance: number): number;
}
TravelStrategy Interface
Now we will implement concrete strategy for our walking , train and car mode of transport
// Concrete Strategy: WalkingStrategy
class WalkingStrategy implements TravelStrategy {
private readonly speed = 5; // Speed in km/h (average walking speed)
calculateETA(totalDistance: number): number {
if (totalDistance < 0) throw new Error("Distance cannot be negative");
return totalDistance / this.speed; // Time in hours
}
}
// Concrete Strategy: CarStrategy
class CarStrategy implements TravelStrategy {
private readonly speed = 60; // Speed in km/h (average car speed)
calculateETA(totalDistance: number): number {
if (totalDistance < 0) throw new Error("Distance cannot be negative");
return totalDistance / this.speed; // Time in hours
}
}
// Concrete Strategy: TrainStrategy
class TrainStrategy implements TravelStrategy {
private readonly speed = 120; // Speed in km/h (average train speed)
calculateETA(totalDistance: number): number {
if (totalDistance < 0) throw new Error("Distance cannot be negative");
return totalDistance / this.speed; // Time in hours
}
}
Lets have a context class who would help us to calculateETA based on our strategy
// Context Class
class TravelPlanner {
private strategy: TravelStrategy;
constructor(strategy: TravelStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: TravelStrategy) {
this.strategy = strategy;
}
calculateTravelETA(totalDistance: number): number {
return this.strategy.calculateETA(totalDistance);
}
}
Now we will have a common function to calculateETA
// Example Usage
function calculateETA(strategy: TravelStrategy) {
const distance = 120; // Distance in kilometers
// Create a context (TravelPlanner)
// and calculate ETA based on given strategy
const planner = new TravelPlanner(strategy);
console.log(`Walking ETA for ${distance} km: ${planner.calculateTravelETA(distance)} hours`);
}
// Calculate ETA for Car
calculateETA(new CarStrategy());
That's how we have implemented strategy pattern for our calculateETA function
Best practices and Common Pitfalls
Lets see best practices and common pitfalls while implementing strategy design pattern
Best Practices:
- Use clear and descriptive names for your interfaces and classes.
- Ensure that all concrete strategies implement the same interface.
- Use dependency injection to provide strategies to your context classes.
- Test each concrete strategy independently to ensure they work as expected.
- Consider using function-based strategies for simpler cases where strategies don't have their own state.
Common Pitfalls:
- Forgetting to set a default strategy in your context class.
- Tightly coupling your context class with specific concrete strategies.
- Not handling cases where no strategy is set or when an invalid strategy is provided.
- Overcomplicating simple scenarios with unnecessary abstraction.
To avoid these pitfalls:
- Always initialise your context with a default strategy if possible.
- Make sure your context class only depends on the Strategy interface.
- Add checks or default behaviors when no strategy is set.
- Evaluate if the pattern is necessary for your use case; sometimes simpler approaches suffice.