Strategy Pattern and Swappable Behavior
The If/Else That Never Stops Growing
You're building a price calculator for an e-commerce app. Different customer types get different pricing: regular customers pay full price, members get 10% off, VIPs get 20% off, and employees get 40% off. Here's what it usually looks like:
function calculatePrice(price: number, customerType: string): number {
if (customerType === "regular") {
return price;
} else if (customerType === "member") {
return price * 0.9;
} else if (customerType === "vip") {
return price * 0.8;
} else if (customerType === "employee") {
return price * 0.6;
}
return price;
}
Add "seasonal VIP" next month. Add "student" the month after. Add "wholesale" for B2B. This function becomes a nightmare. Every new pricing rule means editing the same function, and every edit risks breaking existing rules. The Strategy pattern fixes this by extracting each algorithm into its own function and swapping them at runtime.
Think of the Strategy pattern like a GPS app choosing between routes. The destination (result) is the same, but the strategy for getting there changes — fastest route, shortest distance, avoid highways, scenic route. You pick the strategy before starting. The GPS doesn't have a giant if/else for every routing algorithm. Each strategy is a separate module, and the GPS just calls whichever one you selected.
The Structure
The Strategy pattern has three parts:
- Strategy interface — defines what every strategy must do
- Concrete strategies — different implementations of that interface
- Context — the object that uses a strategy without knowing which one
type PricingStrategy = (price: number) => number;
const regularPricing: PricingStrategy = (price) => price;
const memberPricing: PricingStrategy = (price) => price * 0.9;
const vipPricing: PricingStrategy = (price) => price * 0.8;
const employeePricing: PricingStrategy = (price) => price * 0.6;
function createPriceCalculator(strategy: PricingStrategy) {
return {
calculate(price: number): number {
return strategy(price);
},
setStrategy(newStrategy: PricingStrategy) {
strategy = newStrategy;
},
};
}
const calculator = createPriceCalculator(regularPricing);
calculator.calculate(100); // 100
calculator.setStrategy(vipPricing);
calculator.calculate(100); // 80
In JavaScript, strategies are just functions. No need for classes, interfaces with a single method, or elaborate inheritance hierarchies. A function type is the most natural strategy interface in a language with first-class functions.
Sorting Strategies — The Classic Example
Array.prototype.sort is the Strategy pattern built into the language. The comparator function is the strategy:
interface Product {
name: string;
price: number;
rating: number;
createdAt: Date;
}
type SortStrategy<T> = (a: T, b: T) => number;
const byPriceAsc: SortStrategy<Product> = (a, b) => a.price - b.price;
const byPriceDesc: SortStrategy<Product> = (a, b) => b.price - a.price;
const byRating: SortStrategy<Product> = (a, b) => b.rating - a.rating;
const byNewest: SortStrategy<Product> = (a, b) =>
b.createdAt.getTime() - a.createdAt.getTime();
const byName: SortStrategy<Product> = (a, b) =>
a.name.localeCompare(b.name);
function sortProducts(products: Product[], strategy: SortStrategy<Product>): Product[] {
return [...products].sort(strategy);
}
const sorted = sortProducts(products, byPriceAsc);
The sortProducts function never changes. New sort orders mean new strategy functions, not modifications to existing code. And because strategies are plain functions, you can compose them:
function composeStrategies<T>(...strategies: SortStrategy<T>[]): SortStrategy<T> {
return (a, b) => {
for (const strategy of strategies) {
const result = strategy(a, b);
if (result !== 0) return result;
}
return 0;
};
}
const byRatingThenPrice = composeStrategies(byRating, byPriceAsc);
sortProducts(products, byRatingThenPrice);
Production Scenario: Form Validation Strategies
Real-world forms need different validation rules for different contexts. A registration form validates email differently than a login form. Strategy pattern makes this clean:
type ValidationStrategy = (value: string) => string | null;
const required: ValidationStrategy = (value) =>
value.trim() === "" ? "This field is required" : null;
const minLength = (min: number): ValidationStrategy => (value) =>
value.length < min ? `Must be at least ${min} characters` : null;
const maxLength = (max: number): ValidationStrategy => (value) =>
value.length > max ? `Must be at most ${max} characters` : null;
const emailFormat: ValidationStrategy = (value) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : "Invalid email format";
const passwordStrength: ValidationStrategy = (value) => {
if (!/[A-Z]/.test(value)) return "Must contain an uppercase letter";
if (!/[0-9]/.test(value)) return "Must contain a number";
if (!/[^A-Za-z0-9]/.test(value)) return "Must contain a special character";
return null;
};
function composeValidators(...strategies: ValidationStrategy[]): ValidationStrategy {
return (value) => {
for (const strategy of strategies) {
const error = strategy(value);
if (error) return error;
}
return null;
};
}
Now field validation is declarative:
const validateUsername = composeValidators(
required,
minLength(3),
maxLength(20)
);
const validatePassword = composeValidators(
required,
minLength(8),
passwordStrength
);
const validateEmail = composeValidators(required, emailFormat);
validateUsername(""); // "This field is required"
validateUsername("ab"); // "Must be at least 3 characters"
validatePassword("weak"); // "Must be at least 8 characters"
validatePassword("Weakpass1"); // "Must contain a special character"
validateEmail("test@x.com"); // null (valid)
Each validator is independent, testable, and reusable across forms. Adding a new validation rule means writing one function, not editing a monolithic validate method.
React Rendering Strategies
The Strategy pattern is powerful for swapping rendering approaches based on context:
interface ListRenderStrategy<T> {
render(items: T[]): React.ReactElement;
label: string;
}
function createGridStrategy<T extends { id: string; name: string; image: string }>(
): ListRenderStrategy<T> {
return {
label: "Grid",
render(items) {
return (
<div className="grid grid-cols-3 gap-4">
{items.map(item => (
<div key={item.id} className="rounded-lg border p-4">
<img src={item.image} alt={item.name} />
<h3>{item.name}</h3>
</div>
))}
</div>
);
},
};
}
function createListStrategy<T extends { id: string; name: string }>(
): ListRenderStrategy<T> {
return {
label: "List",
render(items) {
return (
<ul className="space-y-2">
{items.map(item => (
<li key={item.id} className="flex items-center gap-2 p-2 border-b">
{item.name}
</li>
))}
</ul>
);
},
};
}
The consumer component doesn't know which rendering strategy it's using:
function ProductList({ products, strategy }: {
products: Product[];
strategy: ListRenderStrategy<Product>;
}) {
return (
<div>
<span>View: {strategy.label}</span>
{strategy.render(products)}
</div>
);
}
| What developers do | What they should do |
|---|---|
| Creating a Strategy class with a single execute method for each algorithm In Java, you need strategy classes because functions are not first-class. In JavaScript, a function IS the strategy. Wrapping it in a class adds ceremony with zero benefit. Use classes only if strategies need internal state | Use plain functions as strategies in JavaScript — they are first-class values |
| Hardcoding the strategy selection inside the context object If the context contains if/else logic to choose a strategy, you have just moved the problem. The whole point is that strategy selection happens outside the context, at the call site or configuration level | Accept the strategy as a parameter — let the caller decide |
| Using Strategy pattern for two algorithms that will never change Strategy pattern pays off when algorithms multiply or change independently. For a boolean choice that will never grow (e.g., ascending vs. descending), an inline ternary is clearer than the pattern overhead | Use a simple if/else or ternary for stable, binary decisions |
Challenge
Build a text formatting system where different strategies transform text (markdown, plain text, HTML). The system should allow chaining multiple formatters.
Try to solve it before peeking at the answer.
// Requirements:
// 1. Define a TextFormatter type (string) => string
// 2. Create formatters: uppercase, truncate(maxLen), slug, escape HTML
// 3. A pipe() function chains multiple formatters left-to-right
// 4. Each formatter is independent and composable
const titleFormatter = pipe(truncate(50), uppercase);
titleFormatter("hello world this is a very long title");
// "HELLO WORLD THIS IS A VERY LONG TITLE"
const slugFormatter = pipe(lowercase, slug);
slugFormatter("Hello World! Special & Chars");
// "hello-world-special--chars"- 1In JavaScript, strategies are just functions — no need for classes or interfaces with a single method
- 2Strategy separates the algorithm from the code that uses it. New algorithms mean new functions, not modifications to existing code
- 3Compose strategies with higher-order functions like pipe() and composeValidators() for powerful combinations
- 4Use Strategy when you have 3+ interchangeable algorithms. For two fixed options, a simple conditional is clearer
- 5Array.sort's comparator is the most common Strategy pattern in JavaScript — you already use this pattern daily