Imagine you're creating a simple text formatting application that allows users to format their text in different styles like uppercase and lowercase. Without knowledge of the Strategy pattern, you might use conditional statements to handle these different methods of formatting text.
class TextFormatter {
public formatText(style: FormattingStyle, inputText: string): string {
if (style === "lowercase") {
return inputText.toLowerCase();
} else if (style === "uppercase") {
return inputText.toUpperCase();
} else {
throw new Error("Unsupported formatting style");
}
}
}
If you need to add another method, you'll have to change the TextFormatter class itself, violating the Open/Closed Principle. This can also make the code harder to maintain, test, and extend.
Don't complicate things. If it works, it works.
Yes, I know, but I'm telling you the concept with the simplest example. What if these were complex algorithms? Each algorithm can reach hundreds of lines of code, and each time you fix a simple bug or make a slight adjustment, you might create a new bug in an already working code.
Introducing the Strategy Pattern
The Strategy pattern is a behavioral design pattern that defines a family of interchangeable algorithms, encapsulating each algorithm inside a separate class.
This pattern consists of three key components:
- Context: A class that maintains a reference to one of the strategies.
- Strategy Interface: An interface that declares the methods used by different algorithms.
- Concrete Strategies: Classes implementing the Strategy interface, each representing a specific algorithm.
By using the Strategy pattern, we can decouple the algorithms from the object that use them, making it easy to switch between algorithms and add new ones without modifying the existing codebase.
Implementation Steps
Let's implement the Strategy pattern in the text formatting application.
Step 1
Define the Strategy Interface.
interface IStrategy {
formatText: FormatText;
}
Step 2
Create concrete strategies.
class UppercaseStrategy implements IStrategy {
formatText: FormatText = (inputText: string) => {
return inputText.toUpperCase();
};
}
class LowercaseStrategy implements IStrategy {
formatText: FormatText = (inputText: string) => {
return inputText.toLowerCase();
};
}
Step 3
Create a context.
class Strategy {
private strategy: IStrategy;
constructor(strategy: IStrategy) {
if (!strategy) {
throw new Error('Strategy argument cannot be null or undefined');
}
this.strategy = strategy;
}
public setStrategy(strategy: IStrategy): void {
if (!strategy) {
throw new Error('Strategy argument cannot be null or undefined');
}
this.strategy = strategy;
}
public executeStrategy(inputText: string): string {
return this.strategy.formatText(inputText);
}
}
With the Strategy pattern implemented, client code can create a new strategy object and pass it to the context. The context has a setter that allows the client to replace the strategy at runtime.
const strategy = new Strategy(new UppercaseStrategy());
console.log('Strategy is set to uppercase formatting');
console.log(strategy.executeStrategy('SOLID Principle')); // SOLID PRINCIPLE
strategy.setStrategy(new LowercaseStrategy());
console.log('Strategy is set to lowercase formatting');
console.log(strategy.executeStrategy('SOLID Principle')); // solid principle
Conclusion
One popular real-world example where the Strategy Pattern has been applied is PassportJS, an authentication middleware for Node.js. The code is highly extensible, allowing developers around the world to easily plug in their own authentication strategies without modifying the main code. This adherance to the Open/Closed Principle leads to cleaner, more maintanable, and more extensible code.
If you want to dive deeper into design patterns, and explore more examples in TypeScript, feel free to subscribe to my newsletter or visit my repository on design patterns in typescript.