Community Article

The Strategy Pattern: The Cleanest Way to Kill an if-else Chain

Strategy is taught as an OO pattern, but in practice it is just a function with a name. How to recognize the if-else chains that genuinely deserve the refactor and the ones that don't.

The Strategy Pattern: The Cleanest Way to Kill an if-else Chain

Strategy is taught as an OO pattern, but in practice it is just a function with a name. How to recognize the if-else chains that genuinely deserve the refactor and the ones that don't.

strategy-pattern
design-patterns
oop
patterns
clean-code
marcusreddy

By @marcusreddy

March 4, 2026

·

Updated May 18, 2026

592 views

9

4.3 (12)

Half the design-pattern books I read in college spent ten pages on Strategy and called it the "algorithm-as-an-object" pattern. That framing was already dated by the late 2000s and is actively misleading in 2026, because in any language with first-class functions (which is every mainstream language now) Strategy is just a function with a name. The classy version with an interface and a hierarchy is overkill for almost every real codebase. The functional version is the same pattern minus the ceremony.

My stance for this article: Strategy is the right tool when you have a runtime choice between behaviors that share a signature. It is the wrong tool when the chain is enumerable at compile time, when the branches do not share a signature, or when the chain is doing input validation and not behavior selection. Most teams refactor too aggressively in the first direction and not aggressively enough in the third. I will show you what each of those looks like.

The smell that tells me Strategy might fit

The smell is a chain of if/else if (or switch) where every branch produces a result of the same shape, and the branch selector is data, not control flow. The canonical example: pricing.

function priceWithDiscount(cartTotal: number, discountCode: string): number {
    if (discountCode === 'FALL10') return cartTotal * 0.9;
    else if (discountCode === 'BLACKFRIDAY') return cartTotal * 0.75;
    else if (discountCode === 'WELCOME5') return Math.max(0, cartTotal - 5);
    else if (discountCode === 'STAFF') return cartTotal * 0.5;
    else if (discountCode === 'BULK20' && cartTotal > 100) return cartTotal * 0.8;
    else return cartTotal;
}

The shape is the same in every branch (take a cartTotal, return a number). The selector is discountCode, which is data: it comes from user input, marketing setup, or a database row. This is the textbook fit.

The Strategy refactor in TypeScript looks like this:

type DiscountFn = (cartTotal: number) => number;

const discounts: Record<string, DiscountFn> = {
    FALL10: (t) => t * 0.9,
    BLACKFRIDAY: (t) => t * 0.75,
    WELCOME5: (t) => Math.max(0, t - 5),
    STAFF: (t) => t * 0.5,
    BULK20: (t) => (t > 100 ? t * 0.8 : t),
};

function priceWithDiscount(cartTotal: number, discountCode: string): number {
    const fn = discounts[discountCode] ?? ((t) => t);
    return fn(cartTotal);
}

Four things changed and they are all wins.

First, every discount lives in one place, with the same signature. A reviewer sees the whole pricing surface in five lines, which is much easier to audit than a fifteen-branch chain.

Second, adding a new discount is a one-line edit, not a new else if in a function that has grown to seventy lines. The set is open for extension.

Third, the data and the dispatch are separate. The dispatch is two lines (fn = discounts[code] ?? identity) and never changes again. The data table can be loaded from a config file or even a database without touching the dispatch.

Fourth, every strategy is independently testable as a pure function. discounts.BULK20(150) returns 120, no setup, no mocks. Compare that to writing a test that exercises one branch of a fifteen-branch function and has to construct the surrounding state to drive the right path.

Notice what I did NOT do: I did not introduce a Discount interface, a DiscountStrategy abstract class, or a DiscountFactory. Those are the GoF version of Strategy and they add nothing here. The function type IS the interface. The map IS the registry. Less code, same pattern.

When the OO version is worth the ceremony

There is a case where the class-based Strategy still earns its keep, and it is when the strategy carries state or has multiple methods.

interface ShippingStrategy {
    cost(weight: number, distance: number): number;
    eta(distance: number): Date;
    label(): string;
}

class GroundShipping implements ShippingStrategy {
    cost(w: number, d: number) { return 5 + 0.05 * w + 0.01 * d; }
    eta(d: number) { return addBusinessDays(new Date(), Math.ceil(d / 200)); }
    label() { return 'Ground (3-5 business days)'; }
}

class NextDayShipping implements ShippingStrategy {
    cost(w: number, d: number) { return 25 + 0.1 * w; }
    eta(_d: number) { return addBusinessDays(new Date(), 1); }
    label() { return 'Next-Day Air'; }
}

If I tried to express that with a Record<string, Fn> I would need three separate maps, one per method, all keyed by the same shipping mode, and reviewers would lose track of which shippers were defined where. The class form keeps the related methods together, which is the actual win the OO form gives you. Use it when the strategy has cohesion across multiple operations. Skip it when the strategy is a single function.

When NOT to refactor an if-else chain into Strategy

This is the part the textbooks skip and the part where I see most teams over-correct. Three counter-cases.

The first counter-case is input validation chains. Code that looks like a tower of if is often guarding against bad input, not selecting behavior:

if (!user) throw new Error('not authenticated');
if (!user.verified) throw new Error('email not verified');
if (user.banned) throw new Error('account suspended');
if (cart.items.length === 0) throw new Error('cart empty');
if (cart.total > MAX_CART) throw new Error('cart over limit');

This is not a Strategy candidate, even though it looks like a chain. The branches do not produce a result of the same shape (each one early-returns or throws), the order matters (you must check authentication before banning status, because banning status only makes sense for an authenticated user), and the selector is not data. Refactoring this into a validators: Validator[] list and looping over them buys nothing and makes the order-of-checks contract implicit. Leave it alone.

The second counter-case is enumerated, closed sets that are unlikely to grow. If your switch has three cases, all three are known at compile time, and there is no reasonable scenario where a fourth is added at runtime, the switch is fine. A switch over a tagged union with TypeScript's exhaustiveness checking is arguably better than a Strategy table, because the compiler will yell when someone adds a fourth case and forgets to handle it. A Record lookup with a fallback silently swallows the new case.

type Animal = { kind: 'dog'; bark: number } | { kind: 'cat'; lives: number } | { kind: 'fish'; depth: number };

function describe(a: Animal): string {
    switch (a.kind) {
        case 'dog': return `dog with ${a.bark}dB bark`;
        case 'cat': return `cat with ${a.lives} lives`;
        case 'fish': return `fish at ${a.depth}m`;
        // no default needed; TypeScript reports a missing case
    }
}

That is not the smell. That is good code.

The third counter-case is when the branches genuinely do different things:

if (event.type === 'page_view') logEvent(event);
else if (event.type === 'purchase') chargeCustomer(event); 
else if (event.type === 'refund') reverseCharge(event);

Each branch calls a function with different arguments and different side effects. The shared shape is illusory; you cannot collapse them into a single signature without loss. This is dispatch, not Strategy. Leave the if-else chain or, if you want one-place-to-edit, build a registry where each entry knows its own signature, but do not pretend it is the same pattern.

The trick I use to decide quickly

When I see a long chain on a code review, I ask myself two questions in order.

  1. Do all the branches have the same input/output shape?
  2. Is the selector a piece of data (a code, a name, a config flag) rather than the result of computation?

If both answers are yes, Strategy is a clean refactor. If only the first is yes, the chain might still be hiding a tagged union and an exhaustive switch (with TypeScript's never trick or the language equivalent) is the better fit. If the first is no, leave the chain alone and worry about something else.

A real-codebase trap: extracting too aggressively

A pattern I keep watching teams repeat: an engineer reads about Strategy, opens an existing file, sees an if-else chain, and refactors it without checking whether the chain meets the two preconditions above. A week later the codebase has six new Record<string, Fn> lookups, each used in exactly one place, each adding a layer of indirection a debugger now has to step through, and the test surface has grown without the test value growing. That is the false-positive rate of this refactor.

The heuristic I tell new engineers: do not refactor an if-else chain into a Strategy until the chain is at least four branches long, the same shape repeats in another part of the code (so the abstraction has somewhere to be reused), or you have been asked to add a new branch and noticed yourself touching three other files to do it. If none of those is true, the chain is local. Local chains are fine. Some of them stay local their whole lives. The Strategy refactor is for the chains that have grown beyond local.

There is a cousin trap on the other side: refusing to refactor because the abstraction "feels too magical". A Record<string, Fn> plus a one-line dispatch is not magical. It is a function call. Resist that resistance when the four preconditions are met.

A subtler payoff: data-driven testing

The payoff I tend to oversell when convincing teammates is that Strategy gives you data-driven tests for free. Once your discounts map is data, you can write one parametrized test that loops over every entry and checks an invariant ("no discount can produce a negative price", "no discount maps a zero total to a non-zero result"). That kind of cross-cutting invariant is awkward to express against a fifteen-branch function and trivial against a Record.

I have used this in payment code, validation code, and tax-rule code. The win compounds: the more strategies you add, the more valuable the cross-cutting test becomes, because you cannot eyeball-check fifty branches by hand.

What about the dispatch fallback?

Look at this line in my discount example again: const fn = discounts[discountCode] ?? ((t) => t);. The fallback is the identity function, meaning unknown codes silently apply no discount. That is a real product decision, and Strategy makes it visible.

In the original if-else, the equivalent decision was the trailing else return cartTotal;, but it was buried in a fifteen-branch tower and easy to miss. In the Strategy version, the fallback is the second clause of a single line and stares the reviewer in the face. That visibility is itself a design improvement, separate from the dispatch mechanics. I have caught two bugs in code review by squinting at a Strategy fallback and asking "is this what we want for unknown inputs". Before the refactor, neither was visible.

A related question I always ask: should an unknown key throw or silently fallback? My default is to throw in development and silently fall back in production, with a logged warning either way. A Record lookup with a hard throw catches typos in code that registers strategies; a silent fallback keeps production traffic flowing if a malformed input arrives. You can implement that with a wrapper that takes the map and returns a dispatcher with the policy baked in.

A nuance I learned the hard way

If the strategy table holds closures over external state, the table is harder to reason about than it looks. A common pitfall:

let currentExchangeRate = 1.0; // updated by a websocket

const priceConverters: Record<string, (usd: number) => number> = {
    EUR: (usd) => usd * currentExchangeRate, // captures the variable, not the value
    GBP: (usd) => usd * currentExchangeRate * 0.85,
};

Those strategies look pure, but they read mutable module state. A test that imports the module sees one rate; a test that imports it later sees a different rate. The Strategy refactor encourages you to think of each entry as a self-contained function, but if the function closes over mutable state, that mental model breaks. The fix is to make currentExchangeRate a parameter (or part of a context object passed to every strategy), not a module variable. I have shipped that bug. It is mortifying when an engineer files a ticket because their test fails on Tuesdays.

The closing question I ask before merging

When I review a PR that introduces a Record<string, SomeFn> lookup, the question I want answered in the description is: who or what populates this map at runtime, and how does a new entry get added in production. If the answer is "only this file", the abstraction is buying very little; the original if-else was fine. If the answer is "any module can register an entry", or "the map is loaded from a database or a config file", the abstraction is paying for itself, because that is exactly the kind of openness an if-else chain cannot give you. That single question separates the cargo-cult applications of Strategy from the ones that are pulling their weight.

Back to Articles