Strategy

shippingCost was nine lines when it shipped. The last time I opened it, it was ninety, and every one of them lived inside a single switch.

function shippingCost(order) {
  switch (order.method) {
    case "standard":  return order.weight * 0.5 + 2;
    case "express":   return order.weight * 0.9 + 6;
    case "overnight": return order.weight * 1.5 + 12;
    case "pickup":    return 0;
    case "locker":    return 1.5;
    // ...eight more cases, and a new one nearly every sprint
    default:
      throw new Error(`unknown method: ${order.method}`);
  }
}

Every pricing experiment started the same way. Someone opened the file, found the switch, and added a case. That is the tell. A function you edit every single time the business grows a new option was built to be edited, and editing is where the bugs breed.

Three things went wrong with this one, and they are the three that always go wrong.

Two experiments in flight meant two people editing the same switch, so every sprint ended in a merge conflict inside a function nobody wanted to think about. The cart preview needed the same numbers, so the switch got pasted there too, and the copies drifted, until the price we quoted stopped matching the price we charged. And there was no way to test “the overnight rule” on its own. You tested the whole function or nothing, because the whole function was one indivisible lump of if this then that.

None of that is a switch problem exactly. It is a coupling problem. Five unrelated pricing rules were welded into one body, so touching one meant touching all of them.

Pull each branch out

Look at what each case really is. A tiny function from an order to a number. So make it one.

const shipping = {
  standard:  (o) => o.weight * 0.5 + 2,
  express:   (o) => o.weight * 0.9 + 6,
  overnight: (o) => o.weight * 1.5 + 12,
  pickup:    () => 0,
  locker:    () => 1.5,
};

Now the dispatcher shrinks to a lookup and a call:

function shippingCost(order) {
  const rate = shipping[order.method];
  if (!rate) throw new Error(`unknown method: ${order.method}`);
  return rate(order);
}

That is the whole pattern. Each pricing rule is an interchangeable function behind a common shape, (order) => number. The dispatcher picks one by key and runs it, and it does not care which one it got.

chosen by a switchchosen by a lookupfunction cost(o) { switch (o.method) { case “std”: … case “express”: … case “pickup”: … case “sameday”: … default: throw }}a new method reopens the blockconst cost = { std: rate, express: rate, pickup: rate, sameday: rate,};costo.methoda new method is one new entry
Same change, two shapes. Adding a method reopens the switch; adding a method appends one entry to the map.

To add same-day delivery now, you write one line and stop. You do not open shippingCost, do not re-read the other twelve rules, do not risk a stray edit to overnight while you are in there. The rules that already work are never in your diff.

In JavaScript, a strategy is a function

If you learned this pattern from a Java or C# book, you met it wearing a lot of clothes. An interface called ShippingStrategy. A class FlatRateStrategy implements ShippingStrategy, another for ExpressStrategy, each with a single calculate method. A Context class that holds a strategy field and forwards to it. Whole files, one per rule.

That ceremony exists to work around a limitation those languages had for a long time: you could not pass behavior around on its own. You could only pass an object, so a one-method object stood in for a function.

JavaScript never had that limitation. A function is already a value. You can name it, store it in an object, pass it as an argument, return it, and call it later. So the interface is just “a function of this shape,” the concrete strategy is just a function, and the context is just whatever holds the reference. The map above is the entire GoF diagram, collapsed into an object literal.

Classes are not banned here. If a strategy needs to carry state or a bit of setup, a small object or a closure that captures config is the right call, and sometimes a class is genuinely the cleanest way to express that. But reach for the function first. Most strategies never need more.

Open for extension, closed for change

The reason this matters has a name: the open-closed principle. Code should be open to new behavior and closed to editing what already works. A switch is the opposite. It is closed to new behavior (you cannot extend it from outside) and wide open to editing (the only way to grow it is to cut into the middle).

The map flips both. New behavior is a new entry, added from outside. The rules already in the object are never touched.

switch: extend by changing what is already therecase “std”: …case “sameday”: … insertedcase “pickup”: …new variantthe whole function is reopened and must be re-testedmap: extend by adding beside what is already therestd: rate, unchangedexpress: rate, unchangedpickup: rate, unchanged+new entry
Open-closed in one picture. A switch grows by cutting into existing code; a map grows by adding beside it.

This is the same argument behind composition over inheritance. You get new behavior by assembling small pieces, not by rewriting a big one. The switch is a monolith you keep amending. The map is a set of parts you keep adding to.

The context should not know which one it got

There is a second win hiding here, and it is the one that makes strategy more than “a nicer switch.”

Whatever calls the strategy holds a single reference and calls it through the common shape. It never names a concrete rule. That means the caller has no idea whether it is running the flat rate, the weight-based rate, or a promo rule you wrote this morning, and it does not want to know.

Checkoutrate(order)rateorder in, number outbyWeightflatRatefreeOverThresholdbound now
The context holds one reference of a fixed shape and calls it. It never learns which concrete function answered.

Because the caller only knows the shape, you can hand the strategy in from outside instead of hard-coding it. That is plain dependency injection: the thing that varies is passed in, not reached for.

function createCheckout({ shippingStrategy }) {
  return {
    quote(order) {
      return order.subtotal + shippingStrategy(order);
    },
  };
}

// wire it up at the edge of the app, once
const checkout = createCheckout({ shippingStrategy: shipping.express });

No class, no this, just a closure that captures the strategy it was given. In a test you inject a fake that returns a fixed number and assert on the total, without going near the real pricing tables. That is the payoff of the caller not knowing: you can lie to it, and it never notices.

interactiveSwap the shipping strategy live

Add the express strategy and watch the dropdown and the counter both grow. You added one object entry. Nothing that computes a price got reopened, and the code doing the lookup never changed.

Choosing at runtime

The strategy does not have to be chosen in the source. That is the point of pulling it out. You can pick it from a config value, a feature flag, a database column, or whatever the user clicked, and the deciding code stays a one-liner.

// config.json → { "shipping": "byWeight" }
const strategy = shipping[config.shipping] ?? shipping.standard;
const cost = strategy(order);

Flip the config, restart, and the whole app charges a different way, with no code change and no deploy of the pricing logic. The same trick drives A/B tests (shipping[bucket(user)]), per-tenant rules (shipping[tenant.plan]), and gradual rollouts.

config picks the strategy; the context stays the sameconfig value“byWeight”“freeOver”pick(cfg)(order)strategiesbyWeightweight * 0.6freeOver0 if over 50the same lookup runs both times; only the value it reads has changed
The config value picks the strategy. The context code that reads it never changes; only the value it looks up does.

This is where strategy earns its place over a switch decisively. A switch’s cases are baked into the source at author time. A strategy map is data, and data can come from anywhere, including runtime.

The lookup has sharp edges

Two things bite people, and both are worth a guard.

The other trap is quieter. Every strategy in a set has to honor the same contract: same arguments in, same kind of thing out. The moment one rule needs an extra parameter the others do not, the caller has to know which one it is holding, and the whole point evaporates. If express needs a zone but flat does not, put zone on the order object every strategy receives. Do not change one strategy’s signature. The shared shape is the contract, and a strategy that breaks it is not interchangeable, it is a special case wearing a costume.

Strategy’s close relatives

A few patterns sit right next to this one, and it helps to know the seams.

Template method solves a similar problem with inheritance instead of composition. You write a base class with the skeleton of an algorithm and leave one step abstract, then subclasses override that step. Strategy hands the varying step in as a value; template method makes you subclass to change it. One is chosen at runtime, the other is fixed when you define the class.

Strategy: composeflatRatebyWeightfreeOvercontextrate slotswap the function at runtimeTemplate method: inheritReport (base)load() fixedformat() overridesave() fixedCsvReportformat = csvPdfReportformat = pdfoverride a step in a subclass
Strategy swaps a function into a slot at runtime. Template method fixes the algorithm in a base class and lets a subclass override one step.

State is strategy that swaps itself. A state machine holds a current behavior, and that behavior decides what the next one should be, so the object rewires its own strategy as events arrive. Strategy usually binds once and stays; state is built to change on every transition. Same mechanism, different intent.

Chain of responsibility runs a sequence of handlers until one of them handles the request, rather than picking exactly one up front. If you have written Express or Koa middleware you already know the shape; the middleware onion is that pattern under load. Strategy chooses one algorithm and calls it. A chain lines several up and passes the request along. Reach for a chain when “which handler applies” is itself dynamic; reach for strategy when you can look the right one up by a key.

When not to bother

Strategy is a way to delete a switch that keeps growing. If the switch is not growing, leave it alone. Better yet, if there is no switch, do not invent one.

Two rules that will never become three do not need a registry. This is fine, and dressing it up would make it worse:

const rate = order.isMember ? memberRate(order) : standardRate(order);

The anti-pattern is the premature strategy map: a single entry, added “so it is easy to extend later,” with a lookup and a default and a comment about future flexibility, all to run the one function that exists. You built a plugin system for a program with no plugins. When the second rule shows up, extract then. It is a two-minute refactor, and you will actually know what the shared shape needs to be, instead of guessing.

Watch for one more disguise. If your “strategies” are all the same shape and differ only by a constant, you do not have behavior, you have data. That is a lookup table, and a lookup table is the right tool:

// not strategy, just a table, and that is correct
const flatRates = { standard: 2, express: 6, overnight: 12 };
const cost = flatRates[order.method];

Reach for strategy when the branches genuinely do different things. When they only return different numbers, a map of numbers is simpler than a map of functions, and simpler wins.

Summary

  • A switch or if-chain that picks behavior by type, and that you edit every time the business grows an option, is the smell. Strategy is the fix: pull each branch into an interchangeable function behind one shared shape.
  • The dispatcher becomes a lookup and a call. Adding a variant is adding one entry, not editing a conditional. That is the open-closed principle in practice: open to new behavior, closed to changing what works.
  • In JavaScript a strategy is usually just a function. Functions are values, so you skip the interface, the concrete classes, and the context class the Java version needs. You have shipped this every time you passed a comparator to sort.
  • The caller holds one reference of a fixed shape and never learns which concrete function it got. That decoupling lets you inject the strategy, pick it from config at runtime, and swap in a fake for tests.
  • Guard the lookup. A plain object inherits toString and friends, so use Object.hasOwn or a Map when the key comes from a user. And keep every strategy to the same contract, or it is not interchangeable.
  • Its cousins: template method varies a step by inheritance instead of composition; a state machine is strategy that swaps itself on each transition; a chain runs many handlers instead of choosing one.
  • Use it when a type-based conditional keeps growing, or you need to choose or inject behavior at runtime. Avoid it when there are two cases that will never multiply (an inline if is honest), or when the branches only return different constants (that is a lookup table, not a strategy).