Skip to content
TypeScript ts patterns 4 min read

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 axisThere is only ever one algorithm
New variants are added over timeThe branches will never change
You want to unit-test each variant in isolationA simple inline if reads more clearly
Callers should inject behaviorIndirection 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.
Last updated June 29, 2026
Was this helpful?