TypeScript & the Strategy Pattern: A Practical Guide

In the world of software development, we often encounter situations where we need to choose an algorithm or behavior at runtime. Imagine you’re building a system that calculates shipping costs. The cost calculation might vary based on the shipping method (e.g., standard, express, overnight), the destination, and the package’s weight. Hardcoding these variations into a single class leads to a messy, difficult-to-maintain codebase. This is where the Strategy Pattern comes to the rescue. This pattern provides a structured way to encapsulate different algorithms, making your code more flexible, extensible, and easier to understand. In this tutorial, we will explore the Strategy Pattern in TypeScript, providing you with a solid foundation for building more robust and maintainable applications.

What is the Strategy Pattern?

The Strategy Pattern is a behavioral design pattern that defines a family of algorithms, encapsulates each one, and makes them interchangeable. This pattern allows the algorithm to vary independently from the clients that use it. In simpler terms, it lets you define a set of algorithms and switch between them at runtime without changing the client code.

Here are the key components of the Strategy Pattern:

  • Context: This class maintains a reference to a strategy object. It uses the strategy to perform its work.
  • Strategy Interface: This interface declares a method that all concrete strategies must implement.
  • Concrete Strategies: These classes implement the strategy interface, each representing a different algorithm.

Why Use the Strategy Pattern?

The Strategy Pattern offers several benefits:

  • Open/Closed Principle: You can add new algorithms without modifying existing code.
  • Code Reusability: Algorithms can be reused across different contexts.
  • Flexibility: The algorithm can be changed at runtime.
  • Reduced Complexity: It simplifies the context class by delegating algorithm selection to the strategies.

Let’s Get Started: A Shipping Cost Example

To illustrate the Strategy Pattern, let’s create a simple shipping cost calculator. We’ll have different shipping methods: Standard, Express, and Overnight. Each method will calculate the cost differently based on the package weight and a base rate.

Step 1: Define the Strategy Interface

First, we’ll create an interface that defines the contract for all shipping strategies. This interface will have a single method, calculateCost, which takes the package weight as input and returns the shipping cost.

// ShippingStrategy.ts
interface ShippingStrategy {
  calculateCost(weight: number): number;
}

Step 2: Create Concrete Strategies

Next, we’ll create concrete strategy classes for each shipping method. Each class will implement the ShippingStrategy interface and provide its own implementation of the calculateCost method.


// StandardShipping.ts
class StandardShipping implements ShippingStrategy {
  private baseRate: number = 2.50;

  calculateCost(weight: number): number {
    return this.baseRate + (weight * 0.50);
  }
}

// ExpressShipping.ts
class ExpressShipping implements ShippingStrategy {
  private baseRate: number = 7.00;

  calculateCost(weight: number): number {
    return this.baseRate + (weight * 1.00);
  }
}

// OvernightShipping.ts
class OvernightShipping implements ShippingStrategy {
  private baseRate: number = 20.00;

  calculateCost(weight: number): number {
    return this.baseRate + (weight * 2.00);
  }
}

Step 3: Create the Context Class

Now, we’ll create the ShippingContext class. This class will hold a reference to the chosen shipping strategy and use it to calculate the shipping cost.


// ShippingContext.ts
class ShippingContext {
  private strategy: ShippingStrategy;

  constructor(strategy: ShippingStrategy) {
    this.strategy = strategy;
  }

  setStrategy(strategy: ShippingStrategy): void {
    this.strategy = strategy;
  }

  calculateShippingCost(weight: number): number {
    return this.strategy.calculateCost(weight);
  }
}

Step 4: Putting it All Together

Finally, let’s use our classes to calculate shipping costs.


// index.ts
// Import the necessary classes
import { ShippingContext } from './ShippingContext';
import { StandardShipping } from './StandardShipping';
import { ExpressShipping } from './ExpressShipping';
import { OvernightShipping } from './OvernightShipping';

// Create a new shipping context with Standard shipping
const context = new ShippingContext(new StandardShipping());

// Calculate the shipping cost for a 5kg package
let cost = context.calculateShippingCost(5);
console.log("Shipping cost (Standard): $" + cost.toFixed(2)); // Output: Shipping cost (Standard): $5.00

// Change the strategy to Express shipping
context.setStrategy(new ExpressShipping());

// Calculate the shipping cost for a 5kg package
cost = context.calculateShippingCost(5);
console.log("Shipping cost (Express): $" + cost.toFixed(2)); // Output: Shipping cost (Express): $12.00

// Change the strategy to Overnight shipping
context.setStrategy(new OvernightShipping());

// Calculate the shipping cost for a 5kg package
cost = context.calculateShippingCost(5);
console.log("Shipping cost (Overnight): $" + cost.toFixed(2)); // Output: Shipping cost (Overnight): $30.00

Common Mistakes and How to Avoid Them

Mistake 1: Over-Engineering

Sometimes, developers try to apply the Strategy Pattern where a simpler solution would suffice. If you only have one or two algorithms, it might be overkill to introduce this pattern. Always evaluate if the added complexity is justified by the benefits.

Mistake 2: Incorrect Strategy Selection

Ensure your context class correctly selects the appropriate strategy based on the given conditions. Using conditional statements (if/else or switch statements) within the context to determine the strategy can negate some of the pattern’s benefits. Consider alternative approaches like a factory pattern or dependency injection to manage strategy selection more elegantly.

Mistake 3: Tight Coupling

Avoid tightly coupling the context class to the concrete strategies. Instead, rely on the strategy interface to maintain loose coupling. This makes it easier to add, remove, and modify strategies without affecting the context class.

Advanced Considerations

Using Dependency Injection

Dependency Injection (DI) is a powerful technique that can be used to inject the strategy into the context class. This promotes loose coupling and makes testing easier.


// ShippingContext.ts
class ShippingContext {
  private strategy: ShippingStrategy;

  constructor(strategy: ShippingStrategy) {
    this.strategy = strategy;
  }

  calculateShippingCost(weight: number): number {
    return this.strategy.calculateCost(weight);
  }
}

// Usage with DI
const standardShipping = new StandardShipping();
const context = new ShippingContext(standardShipping);

Strategy Factories

For more complex scenarios, you can use a strategy factory to create and select the appropriate strategy dynamically. This can simplify the context class and make it more flexible.


// ShippingStrategyFactory.ts
interface StrategyFactory {
  getStrategy(type: string): ShippingStrategy | undefined;
}

class ShippingStrategyFactory implements StrategyFactory {
  getStrategy(type: string): ShippingStrategy | undefined {
    switch (type) {
      case 'standard':
        return new StandardShipping();
      case 'express':
        return new ExpressShipping();
      case 'overnight':
        return new OvernightShipping();
      default:
        return undefined;
    }
  }
}

// Usage
const factory = new ShippingStrategyFactory();
const strategy = factory.getStrategy('express');
if (strategy) {
  const context = new ShippingContext(strategy);
  const cost = context.calculateShippingCost(5);
  console.log(`Shipping cost: $${cost.toFixed(2)}`);
}

Testing Strategies

Unit testing is crucial when using the Strategy Pattern. You should write tests to ensure each concrete strategy calculates the cost correctly and that the context class correctly uses the selected strategy. Mocking the strategy interface can be helpful for isolating and testing the context class.


// Example test using Jest
import { ShippingContext } from './ShippingContext';
import { ShippingStrategy } from './ShippingStrategy';

// Mock the ShippingStrategy interface
class MockShippingStrategy implements ShippingStrategy {
  calculateCost(weight: number): number {
    return 10; // Fixed cost for testing
  }
}

test('ShippingContext should calculate cost using the strategy', () => {
  const mockStrategy = new MockShippingStrategy();
  const context = new ShippingContext(mockStrategy);
  const cost = context.calculateShippingCost(5);
  expect(cost).toBe(10);
});

Summary / Key Takeaways

The Strategy Pattern is a powerful tool for designing flexible and maintainable code. By encapsulating algorithms and making them interchangeable, you can easily adapt your application to changing requirements. Here are the key takeaways:

  • The Strategy Pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.
  • It promotes the Open/Closed Principle, code reusability, and flexibility.
  • It simplifies the context class by delegating algorithm selection to the strategies.
  • Use Dependency Injection and Strategy Factories for more advanced scenarios.
  • Write unit tests to ensure the correct behavior of your strategies and context class.

FAQ

1. What is the main benefit of using the Strategy Pattern?

The main benefit is the ability to change the algorithm used by an object at runtime without modifying the object’s code. This increases flexibility and maintainability.

2. When should I use the Strategy Pattern?

Use the Strategy Pattern when you have multiple algorithms or behaviors that can be used interchangeably and you want to avoid a large conditional statement (e.g., if/else or switch) to select the appropriate algorithm.

3. How does the Strategy Pattern differ from the Template Method Pattern?

The Template Method Pattern defines the structure of an algorithm in a base class, allowing subclasses to override specific steps. The Strategy Pattern, on the other hand, encapsulates different algorithms in separate classes and allows you to switch between them at runtime. The Template Method Pattern focuses on inheritance, while the Strategy Pattern focuses on composition.

4. Can I combine the Strategy Pattern with other design patterns?

Yes, you can. The Strategy Pattern often works well with patterns like the Factory Pattern (for creating strategies) and the Dependency Injection pattern (for injecting strategies into the context). This combination can make your code even more flexible and maintainable.

5. How do I choose the right strategy in the context class?

The context class can choose the strategy in several ways, such as through a constructor parameter, a setter method, or a factory. The choice depends on your specific needs. The goal is to provide a way to select the appropriate strategy based on the context’s requirements without tightly coupling the context to the concrete strategies.

By understanding and implementing the Strategy Pattern, you can create more adaptable and maintainable software systems. This pattern allows you to easily add new algorithms, modify existing ones, and switch between them at runtime, leading to more flexible and robust applications. Remember to consider the trade-offs and evaluate whether the pattern’s benefits outweigh the added complexity for your specific use case. With practice, you’ll find the Strategy Pattern to be a valuable tool in your software design arsenal, enabling you to build applications that are easier to evolve and maintain over time.