open atlas
↑ Back to track
Code patterns & craft CP · 11 · 02

Replace conditional with polymorphism

The larger move under tests: turn a sprawling, repeated type-switch into a polymorphic family one case at a time. Pin behaviour with tests, introduce the interface, migrate cases incrementally, delete the switch last — and only do it when the variation axis is real.

CP Senior ◷ 20 min
Level
FoundationsJuniorMiddleSenior

You know the end state you want: each payment method as its own object, the switch (method) gone, new methods added by writing one class. You’ve seen the principle. But the code in front of you is the before — a switch over five methods, repeated across four files, wired into production, with no tests pinning its behaviour. You can’t see the after; you have to get there without breaking a single one of the cases that currently ship.

This lesson is not “polymorphism beats conditionals” — you’ve met that idea. It’s the mechanics of the move itself: how a senior refactors a live, untested type-switch into a polymorphic family in small, reversible steps where the build and the tests stay green the entire way. The big rewrite is the amateur version. The professional version is a sequence of boring, safe commits.

Goal

After this lesson you can perform “replace conditional with polymorphism” as a disciplined refactoring rather than a rewrite: pin the current behaviour with characterization tests first, introduce the interface and a factory alongside the live switch, migrate the cases one at a time keeping the build green, and delete the original switch only when nothing reads it — and you can name the smell that authorizes the move and the failure mode that should stop you from making it.

1

Pin the behaviour before you touch the structure — characterization tests come first. You cannot safely transform code you can’t observe. Before introducing any interface, write tests that capture what the switch currently does for every case, including the weird ones. These aren’t tests of correct behaviour; they’re tests of actual behaviour — a net under the trapeze.

// characterize the CURRENT fee() for every method — including the 'wire = 0' edge
describe("fee (current behaviour)", () => {
  test.each([
    ["card",   1000, 59],   // 1000 * 0.029 + 30
    ["paypal", 1000, 83],   // 1000 * 0.034 + 49
    ["wire",   1000, 0],
    ["ach",    1000, 0],    // legacy: ACH was silently free — pin it even if it's a bug
  ])("%s on %d → %d", (method, amount, expected) => {
    expect(fee({ method, amount } as Payment)).toBe(expected);
  });
});

If ach returning 0 is actually a latent bug, you still pin it. The refactor must preserve behaviour exactly; fixing the bug is a separate commit with its own test change. Mixing the two is how “just a refactor” ships a regression.

2

Introduce the interface and a factory beside the live switch — don’t replace anything yet (parallel change). Add the new structure next to the old code so both exist at once. Nothing calls the new classes yet; the switch still runs in production. This is the expand phase of a parallel change: you build the destination before you redirect traffic to it.

interface PaymentMethod {
  fee(amount: number): number;
}

class Card implements PaymentMethod {
  fee(amount: number) { return amount * 0.029 + 30; }
}

// factory: the ONE place that still knows the string→type mapping
function methodFor(p: Payment): PaymentMethod {
  switch (p.method) {
    case "card": return new Card();
    // others fall through to the old path for now
    default: return new LegacyAdapter(p);
  }
}

The LegacyAdapter delegates back to the original switch for any case not yet migrated. Now the system runs on the new dispatch path for card and the old path for everything else — and your characterization tests still pass, because behaviour is identical. Commit here.

3

Migrate one case at a time; run the tests after each. This is the heart of the move. For each variant: create its class, copy the case body into the method, point the factory at it, delete that one case from the original switch, run the suite. One case, one green bar, one commit. If a step goes red, you’ve isolated the break to a single small change you can revert in seconds.

class Wire implements PaymentMethod {
  fee(_amount: number) { return 0; }   // copied verbatim from `case "wire"`
}
// factory gains: case "wire": return new Wire();
// original switch loses: case "wire": return 0;
// → run tests → green → commit

Resist the urge to migrate all five at once “to save commits.” The whole value of the technique is that a mistake is bounded to one case and reversible. Five-at-once turns a safe sequence into a risky rewrite that just happens to be split across fewer keystrokes.

4

Delete the switch last — only when nothing reads it. When every case has moved onto a class and the factory’s default/LegacyAdapter branch is unreachable, the original switch and the adapter are dead code. Now delete them. The contraction phase: the old path is gone, the new path is the only path, and the tests that passed at every step still pass.

function methodFor(p: Payment): PaymentMethod {
  switch (p.method) {           // now the ONLY switch left — a pure factory
    case "card":   return new Card();
    case "paypal": return new PayPal();
    case "wire":   return new Wire();
    case "ach":    return new Ach();
  }
}
// every OTHER switch on p.method across the codebase is now gone

Notice one switch survives — the factory. That’s correct and intended: you’ve collapsed N scattered type-switches into a single point of dispatch. Adding crypto is now one class plus one factory line, not a hunt across four files. If you also want the compiler to enforce exhaustiveness, a Record<Payment["method"], …> dispatch map replaces even the factory switch.

Worked example

Before — the live, repeated switch you inherit:

// fees.ts
function fee(p: Payment): number {
  switch (p.method) {
    case "card":   return p.amount * 0.029 + 30;
    case "paypal": return p.amount * 0.034 + 49;
    case "wire":   return 0;
  }
}
// label.ts, refund.ts, report.ts — the SAME switch (p.method), three more times

The move, as commits (each one ships green):

  1. Pin. Characterization tests for fee, label, refund across card/paypal/wire. They pass against the current code.
  2. Expand. Add interface PaymentMethod { fee(a): number; label(): string; refund(a): number }, a Card class, and methodFor(p) returning a LegacyAdapter for non-migrated cases. Production behaviour unchanged. Commit.
  3. Migrate card. Fill Card’s three methods from the three switches’ card cases; point methodFor at Card; delete case "card" from all three switches. Tests green. Commit.
  4. Migrate wire, then paypal — same recipe, one per commit.
  5. Contract. All three original switches are now empty; delete them and the LegacyAdapter. The only switch left is the factory in methodFor. Tests green. Commit.

After:

class Card implements PaymentMethod {
  fee(a: number)    { return a * 0.029 + 30; }
  label()           { return "Card"; }
  refund(a: number) { return a; }
}
const total = methodFor(p).fee(p.amount);   // call sites no longer switch

The diff is large, but it landed as six small, individually-revertible steps that were each green. That is what makes it a refactoring and not a rewrite-with-a-prayer.

Why this works

Why the LegacyAdapter and parallel path instead of just rewriting fee outright? Because it lets the production system run on a mix of old and new dispatch while you migrate, so you never have a window where some cases are broken. The expand/migrate/contract shape (a “parallel change”) keeps the build green and deployable at every commit — which matters enormously when the switch is wired into something you can’t take offline. The cost is a few throwaway lines (the adapter) that you delete at the end; the payoff is that a botched case-migration is a one-line revert, not a rolled-back release.

Common mistake

The failure mode is doing the whole ceremony for a distinction that doesn’t justify it. A two-case, stable if (status === "active") … else … does not need an ActiveAccount/ClosedAccount hierarchy — a small switch or a lookup table is clearer and cheaper to read. The trigger for this refactor is a real, observed smell: the same type-switch duplicated across several files, with new variants actually arriving. Absent that smell, performing the move buys you indirection and scattered logic in exchange for flexibility you’ll never use — over-abstraction that the next reader has to pay for. The move is a response to a smell, never a default. Before you start, ask: is the axis of variation real and growing, or am I just pattern-matching on “switch = bad”?

Check yourself
Quiz

You're replacing a repeated five-case type-switch with a polymorphic family in a service that's live in production and has no tests. What is the correct FIRST step?

Recap

“Replace conditional with polymorphism” as a larger move is choreography, not a rewrite. Pin the current behaviour with characterization tests, expand by standing up the interface and a factory beside the live switch, migrate cases one at a time so the build and tests stay green after every step, then contract by deleting the original switch once nothing reads it — collapsing N scattered type-switches into one point of dispatch where a new variant is a single class. This is the Open/Closed Principle reached by transformation, under a net, in reversible commits. But the move is authorized by a smell — the same switch repeated across files with variants still arriving — not by a reflex. Performing the full ceremony for a two-case, stable distinction trades a clear local switch for indirection no one needed. Refactor toward polymorphism when the variation axis is real and growing; otherwise leave the small switch alone.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.