Strategy Pattern
The Strategy pattern lets you define a family of interchangeable algorithms, encapsulate each one behind a shared interface, and select which to use at runtime. Instead of hard-coding a branching if/switch that grows every time a new variation appears, you make the behavior a pluggable object that the caller injects. TypeScript expresses this elegantly because a strategy is often just a function type or a small interface. This page shows the problem, an idiomatic implementation, a function-first variant, and when the pattern earns its keep.
The problem it solves
When a single function must behave differently depending on a mode, naive code accumulates conditionals. Every new case forces you to edit the same function, risking regressions and violating the open/closed principle.
// ❌ A switch that grows without bound and is hard to test in isolation
function getPrice(total: number, customer: "regular" | "vip" | "employee") {
switch (customer) {
case "regular": return total;
case "vip": return total * 0.9;
case "employee": return total * 0.7;
}
}
Adding a “partner” tier means editing getPrice, retesting all branches, and coupling pricing rules to one place. Strategy replaces the switch with substitutable objects.
The idiomatic implementation
Define an interface for the algorithm, implement each variant as its own class, and have the context hold a reference to whichever strategy is active. The context delegates rather than deciding.
interface DiscountStrategy {
apply(total: number): number;
}
class NoDiscount implements DiscountStrategy {
apply(total: number): number {
return total;
}
}
class PercentageDiscount implements DiscountStrategy {
constructor(private readonly rate: number) {}
apply(total: number): number {
return total * (1 - this.rate);
}
}
class Checkout {
constructor(private strategy: DiscountStrategy) {}
setStrategy(strategy: DiscountStrategy): void {
this.strategy = strategy;
}
total(amount: number): number {
return this.strategy.apply(amount);
}
}
const checkout = new Checkout(new NoDiscount());
console.log(checkout.total(100)); // 100
checkout.setStrategy(new PercentageDiscount(0.1));
console.log(checkout.total(100)); // 90
Checkout knows nothing about how a discount is computed — it only knows the DiscountStrategy contract. New tiers are new classes; existing code is untouched.
The function-first variant
In TypeScript a strategy with a single method is often better modeled as a function type. This removes class boilerplate and composes naturally with closures.
type DiscountFn = (total: number) => number;
const strategies = {
regular: (t) => t,
vip: (t) => t * 0.9,
employee: (t) => t * 0.7,
} satisfies Record<string, DiscountFn>;
function priceFor(total: number, strategy: DiscountFn): number {
return strategy(total);
}
priceFor(100, strategies.vip); // 90
priceFor(100, (t) => Math.max(t - 5, 0)); // ad-hoc strategy inline
The satisfies operator validates that every entry matches DiscountFn while preserving the exact key literals, so strategies.vip stays strongly typed. Callers can also pass a one-off lambda without declaring a class.
Choosing a strategy at runtime
A lookup table maps an external value (config, user input, feature flag) to a concrete strategy, keeping selection logic in one small, easily tested place.
const registry: Record<string, DiscountStrategy> = {
none: new NoDiscount(),
vip: new PercentageDiscount(0.1),
staff: new PercentageDiscount(0.3),
};
function resolve(key: string): DiscountStrategy {
return registry[key] ?? new NoDiscount();
}
The ?? new NoDiscount() fallback keeps the function total even for unknown keys, which is safer than letting an undefined strategy crash the caller.
When to use and pitfalls
| Use Strategy when… | Avoid it when… |
|---|---|
| Behavior varies along one clear axis | There is only ever one algorithm |
| New variants are added over time | The branches will never change |
| You want to unit-test each variant in isolation | A simple inline if reads more clearly |
| Callers should inject behavior | Indirection would obscure trivial logic |
Prefer function-typed strategies for single-method behavior; reserve the class form for strategies that carry their own state or need multiple related methods.
Reach for Strategy when a growing switch is becoming a maintenance burden or when consumers need to plug in their own behavior. Do not introduce it speculatively — a single, stable algorithm gains nothing from the extra indirection.
Best Practices
- Model single-method strategies as function types; use interfaces for stateful, multi-method ones.
- Keep the context dumb: it should delegate to the strategy, never branch on its kind.
- Use
satisfies Record<...>for strategy maps to keep both validation and literal key types. - Provide a sensible default strategy so selection stays total for unknown inputs.
- Inject strategies through the constructor to keep the context testable.
- Name strategies for behavior (
PercentageDiscount), not for the caller that uses them. - Resist adding Strategy until at least two real variants exist.