Strategy is the pattern for "same job, different rules". Calculating a price, choosing a shipping carrier, picking a retry policy, sorting a list: the caller always wants one answer, but how that answer is computed depends on context. Strategy puts each way of computing it behind the same interface, so the caller can switch rules without knowing how any of them work.

Difficulty: ★★★☆☆. Popularity: ★★★★☆. Once you see it, you will notice it in half the libraries you use.

The problem: a price function that never stops growing

An e-commerce site starts with one rule: the price is the price. Then marketing asks for pre-orders, then flash sales, then a member discount, then a mega sale on 11.11. Each request adds a branch.

type PriceContext = { basePrice: number; type: string; isMember?: boolean };

export function finalPrice({ basePrice, type, isMember }: PriceContext): number {
  if (type === "pre-order") {
    return basePrice * 0.8;
  } else if (type === "flash-sale") {
    return basePrice > 200_000 ? basePrice - 50_000 : basePrice * 0.9;
  } else if (type === "member") {
    return isMember ? basePrice * 0.95 : basePrice;
  } else if (type === "mega-sale") {
    const discounted = basePrice * 0.7;
    return Math.max(discounted, basePrice - 300_000);
  }
  return basePrice;
}

Every problem here gets worse with each new campaign:

  • One function, every rule. The flash-sale rule and the member rule live in the same block. A typo in one breaks checkout for all of them.
  • type: string. Nothing stops "flash_sale" from silently falling through to the full price.
  • Untestable in isolation. To test the mega-sale rule you call the whole function and hope the other branches do not interfere.
  • Closed to extension, open to modification. Adding a campaign means editing and redeploying code that every campaign depends on. That is the exact opposite of the Open/Closed Principle from chapter 4.

Level 1 to Level 2: strategies as a lookup of functions

The first refactor needs no classes. Each rule becomes a function with the same signature, and a map from campaign name to function replaces the if chain.

type PricingStrategy = (basePrice: number, ctx: { isMember: boolean }) => number;

const strategies = {
  default: (price) => price,
  "pre-order": (price) => price * 0.8,
  "flash-sale": (price) => (price > 200_000 ? price - 50_000 : price * 0.9),
  member: (price, { isMember }) => (isMember ? price * 0.95 : price),
  "mega-sale": (price) => Math.max(price * 0.7, price - 300_000),
} satisfies Record<string, PricingStrategy>;

export type Campaign = keyof typeof strategies;

export function finalPrice(basePrice: number, campaign: Campaign, isMember = false): number {
  return Math.round(strategies[campaign](basePrice, { isMember }));
}

console.log(finalPrice(250_000, "flash-sale")); // 200000
console.log(finalPrice(1_500_000, "mega-sale")); // 1200000
console.log(finalPrice(100_000, "member", true)); // 95000

That is already the Strategy pattern: a family of interchangeable algorithms behind one interface (PricingStrategy), selected at runtime. What improved:

  • Each rule is a pure function you can unit test with two lines.
  • Campaign is a union derived from the map, so finalPrice(100, "flash_sale") is a compile error.
  • satisfies checks every entry against the signature without widening the keys to string.
  • Adding a campaign is adding one entry. Existing rules are not touched.

In JavaScript, where functions are values, this is the version you should reach for first.

Level 3: strategy objects when rules carry state and metadata

Function maps stop being enough when each strategy needs more than one operation: a label for the UI, a check for whether it applies, configuration loaded from the database. That is when an interface with several members earns its place.

interface Cart {
  subtotal: number;
  items: { sku: string; price: number; quantity: number }[];
  customer: { isMember: boolean; orderCount: number };
}

interface DiscountStrategy {
  readonly code: string;
  readonly label: string;
  appliesTo(cart: Cart, now: Date): boolean;
  discount(cart: Cart): number;
}

class PercentOff implements DiscountStrategy {
  private readonly maxDiscount: number;
  private readonly eligible: (cart: Cart) => boolean;

  constructor(
    readonly code: string,
    readonly label: string,
    private readonly percent: number,
    options: { maxDiscount?: number; eligible?: (cart: Cart) => boolean } = {},
  ) {
    this.maxDiscount = options.maxDiscount ?? Number.POSITIVE_INFINITY;
    this.eligible = options.eligible ?? (() => true);
  }
  appliesTo(cart: Cart) {
    return this.eligible(cart);
  }
  discount(cart: Cart) {
    return Math.min(cart.subtotal * (this.percent / 100), this.maxDiscount);
  }
}

class FirstOrderVoucher implements DiscountStrategy {
  readonly code = "WELCOME";
  readonly label = "50,000đ off your first order over 200,000đ";
  appliesTo(cart: Cart) {
    return cart.customer.orderCount === 0 && cart.subtotal >= 200_000;
  }
  discount() {
    return 50_000;
  }
}

class TimeBoxed implements DiscountStrategy {
  constructor(
    private readonly inner: DiscountStrategy,
    private readonly from: Date,
    private readonly to: Date,
  ) {}
  get code() {
    return this.inner.code;
  }
  get label() {
    return this.inner.label;
  }
  appliesTo(cart: Cart, now: Date) {
    return now >= this.from && now < this.to && this.inner.appliesTo(cart, now);
  }
  discount(cart: Cart) {
    return this.inner.discount(cart);
  }
}

The class that uses the strategies (the GoF book calls it the context) does not know any of them by name:

class Checkout {
  constructor(private readonly available: DiscountStrategy[]) {}

  /** Best single discount for this cart. Strategies decide if they apply; checkout only compares. */
  bestDiscount(cart: Cart, now = new Date()) {
    let best: { strategy: DiscountStrategy; amount: number } | undefined;
    for (const strategy of this.available) {
      if (!strategy.appliesTo(cart, now)) continue;
      const amount = Math.min(strategy.discount(cart), cart.subtotal);
      if (!best || amount > best.amount) best = { strategy, amount };
    }
    return best;
  }
}

const checkout = new Checkout([
  new PercentOff("MEMBER5", "5% off for members", 5, { eligible: (cart) => cart.customer.isMember }),
  new FirstOrderVoucher(),
  new TimeBoxed(
    new PercentOff("1111", "11.11: 30% off, up to 300,000đ", 30, { maxDiscount: 300_000 }),
    new Date("2026-11-11T00:00:00+07:00"),
    new Date("2026-11-12T00:00:00+07:00"),
  ),
]);

const cart: Cart = {
  subtotal: 800_000,
  items: [{ sku: "TSHIRT", price: 400_000, quantity: 2 }],
  customer: { isMember: true, orderCount: 0 },
};

console.log(checkout.bestDiscount(cart, new Date("2026-11-11T10:00:00+07:00"))?.strategy.code); // 1111
console.log(checkout.bestDiscount(cart, new Date("2026-11-20T10:00:00+07:00"))?.strategy.code); // WELCOME

A few things to notice:

  • Checkout is closed for modification. A new campaign is a new class (or a new instance of an existing one with different numbers), registered in the list.
  • TimeBoxed wraps another strategy. That is a Decorator, and patterns combining like this is normal.
  • The list of strategies can come from configuration or a database, so marketing can launch a campaign without a deploy.

How the pattern looks in modern code

You already use Strategy every day, usually without the name:

  • array.sort((a, b) => a.price - b.price): the comparator is a strategy.
  • Passport.js strategies (new GoogleStrategy(...), new JwtStrategy(...)): the name is literal.
  • NestJS providers swapped by configuration (useClass: process.env.MAIL === "ses" ? SesMailer : SmtpMailer).
  • Retry and backoff policies in HTTP clients, compression algorithms in a bundler, serializers in a logger.

When to use it, and when not to

Use it when:

  • You have three or more variants of the same calculation, and more are coming.
  • Variants change for different reasons or are owned by different people (marketing owns campaigns, finance owns tax rules).
  • You want to choose the behaviour at runtime from configuration or user input.

Skip it when:

  • There are two variants and no sign of a third. A ternary is fine.
  • The variants share almost all their logic and differ by a number. That is a parameter, not a strategy.

Common pitfalls

  1. Leaking the selection back into the caller. If every caller does if (campaign === "x") use(strategyX), you moved the if instead of removing it. Keep selection in one place: a map, a registry or a factory (chapter 5).
  2. Strategies that need to know about each other. If the flash-sale rule checks whether the member rule ran, the rules are not independent. Combine them explicitly with a composite or decorator instead.
  3. Fat interfaces. A strategy interface with eight methods, half of which return null for most implementations, is a sign the variants are not really the same job.
  4. Floating-point money. All the examples round at the end. In a real system, store money as integers in the smallest unit and round in one place.
  5. Trusting keys from outside. strategies[req.query.campaign] with an unknown key is undefined, and calling it throws is not a function deep inside checkout. The Campaign union only protects code you compile. Validate runtime input at the edge (campaign in strategies, or a schema) and fall back to default explicitly.

Exercises

  1. Add a "buy-2-get-1" campaign to the Level 2 version where the cheapest of every three items is free. What did you have to change in the signature?
  2. Change Checkout.bestDiscount to allow stacking: a member discount can combine with one voucher, but two vouchers cannot. Where should that rule live?
  3. Load the list of PercentOff campaigns from a JSON file at startup, so a new percentage campaign needs no code change.
  4. Write unit tests for FirstOrderVoucher and TimeBoxed without constructing a Checkout.