Polymorphism over conditionals
When the same type-switch appears in many places, each new variant forces edits everywhere. Move per-case behaviour onto the types or a dispatch map so dispatch is one mechanism — but only when the switch is repeated and variants keep arriving.
You add a new payment method — crypto — and the compiler doesn’t help you. You add a case 'crypto' to the fee calculator, ship it, and a week later finance reports that crypto refunds are wrong, crypto receipts say “unknown method”, and the reconciliation job skips crypto entirely. Three other files had the same switch (method) and you only touched one. The feature wasn’t hard; finding every place that switches on the type was.
That shape has a name: shotgun surgery — one conceptual change scattered across many edits. When the same type-switch is duplicated, every new variant is a hunt, and a missed site is a silent bug. The fix is to make dispatch happen in exactly one mechanism.
After this lesson you can recognise a repeated type-switch as shotgun surgery; perform the “replace conditional with polymorphism” refactor in TypeScript using either subtype methods or a strategy/dispatch map; and — the senior part — decide when not to, because a single small never-growing switch is clearer than a class hierarchy, and abstraction is a cost you only pay when the type really varies in many places.
The smell is the same switch repeated, not a switch by itself. A lone switch is fine. The problem is when the same set of cases — the same if (type === ...) ladder — reappears in fee calculation, receipt rendering, refund handling, and reporting. Now the knowledge “what kinds of payment exist and how each behaves” is smeared across N files.
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;
}
}
function label(p: Payment): string {
switch (p.method) { // <-- same axis of variation, second site
case "card": return "Card";
case "paypal": return "PayPal";
case "wire": return "Wire transfer";
}
}Adding crypto means editing both — and every other site that switches on p.method. The cost of a new variant scales with the number of sites that switch on the type.
Replace conditional with polymorphism: move each case’s behaviour onto the type. Give each variant an object that knows its own fee, label, refund. Dispatch then happens once, by the language, when you call payment.fee() — the per-method switch disappears from every call site.
interface PaymentMethod {
fee(amount: number): number;
label(): string;
}
class Card implements PaymentMethod {
fee(amount: number) { return amount * 0.029 + 30; }
label() { return "Card"; }
}
class Wire implements PaymentMethod {
fee(_amount: number) { return 0; }
label() { return "Wire transfer"; }
}Now fee and label callers just write method.fee(amount) and method.label(). Adding crypto is one new class — a single, additive edit — and existing call sites don’t change. This is the Open/Closed Principle made concrete: open for extension (new class), closed for modification (no existing switch touched).
A dispatch map is the lighter-weight form — same win, less ceremony. You don’t always need a class per variant. When the per-case behaviour is small and data-like, a lookup keyed by the type collapses the same N switches into one table. This is often the right amount of structure in TypeScript.
const METHODS: Record<Payment["method"], {
fee: (amount: number) => number;
label: string;
}> = {
card: { fee: a => a * 0.029 + 30, label: "Card" },
paypal: { fee: a => a * 0.034 + 49, label: "PayPal" },
wire: { fee: () => 0, label: "Wire transfer" },
};
const feeOf = (p: Payment) => METHODS[p.method].fee(p.amount);
const labelOf = (p: Payment) => METHODS[p.method].label;Adding crypto is one new entry. And because the key type is Payment["method"], TypeScript forces the table to be exhaustive — leave out a method and it won’t compile. That compiler check is the dispatch-map’s edge over a hand-written switch with no default.
Senior tradeoff: polymorphism removes repeated conditionals but adds indirection and scatters one method’s logic across files. The decision isn’t “polymorphism good, switch bad.” It’s a cost-benefit you can almost write down:
lean to polymorphism when (number of sites that switch on this type) × (rate at which new variants arrive) is high.
- Many sites, many variants → polymorphise. A new variant is one additive unit; the switches vanish.
- One site, stable set → keep the switch. A class hierarchy here buys nothing: you’ve traded a five-line local
switchfor a file you must open to understand one behaviour. To see howfeeworks across methods you now read three files instead of one block.
The failure mode is the second case done wrong: a hierarchy for a two-case, stable distinction. if (isAdmin) … else … does not need an AdminUser/RegularUser polymorphic tree. That’s over-abstraction — indirection with no extension pressure to justify it. The expensive-to-undo mistake is usually too much structure for a distinction that was never going to grow.
Before — the same switch in two places (and implicitly more).
type Payment = { method: "card" | "paypal" | "wire"; amount: number };
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;
}
}
function settlementDays(p: Payment): number {
switch (p.method) { // second site, same axis
case "card": return 2;
case "paypal": return 1;
case "wire": return 3;
}
}Adding crypto means finding and editing both switches — and any others. A default-less switch might warn, but only if you remembered to make the return type exhaustive; nothing stops a teammate from adding a case in one file and forgetting the other.
After — one dispatch map; dispatch is a single mechanism.
type Method = "card" | "paypal" | "wire";
const METHODS: Record<Method, { fee: (a: number) => number; settlementDays: number }> = {
card: { fee: a => a * 0.029 + 30, settlementDays: 2 },
paypal: { fee: a => a * 0.034 + 49, settlementDays: 1 },
wire: { fee: () => 0, settlementDays: 3 },
};
const fee = (p: Payment) => METHODS[p.method].fee(p.amount);
const settlementDays = (p: Payment) => METHODS[p.method].settlementDays;Now everything about a method lives in one row. Adding crypto is a single new entry, and Record<Method, …> makes the compiler reject a table that forgets a method — the exhaustiveness the scattered switches never enforced. The win wasn’t “objects”; it was collapsing many dispatch points into one.
▸Why this works
Why does this map onto Open/Closed at all? OCP says a module should be open for extension but closed for modification. The repeated switch is the opposite: every extension (a new variant) requires modifying existing code at every switch site. Moving dispatch onto the type — or into one table — makes “new variant” an additive operation: you add a class or a row, and the closed code (the call sites) is untouched. That’s the whole mechanism behind OCP. It is not a goal in itself; it’s the property you get for free once dispatch lives in one place, and it’s only worth engineering for when variants actually keep arriving.
▸Common mistake
The over-correction is worse than the smell. Seeing one switch, a developer extracts a PaymentMethod interface, three classes, a factory, and an abstract base — for a set of methods that hasn’t changed in two years and is switched on in exactly one place. Now reading “how is the card fee computed?” means opening Card.ts, and there are four files where there used to be one block. You paid indirection up front and bought zero extension flexibility, because the type wasn’t varying. Rule of thumb: don’t polymorphise a switch you can see the whole of on one screen and that nobody is adding cases to. Refactor toward polymorphism when the repetition and the variant churn are real — not on the first sighting of a conditional.
A codebase has a `switch (shape.kind)` for `area` repeated in five files, and the team adds a new shape roughly every sprint. A second `switch (user.role)` for one permission check appears in exactly one place and hasn't changed in a year. Which should you replace with polymorphism?
A type-switch is only a smell when the same switch is repeated: then every new variant is shotgun surgery — N edits, with a missed site as a silent bug. Replace conditional with polymorphism by moving each case’s behaviour onto the type (subtype methods) or into a dispatch map keyed by the type, so dispatch becomes one mechanism and a new variant is a single additive edit — the Open/Closed Principle, concretely. But polymorphism is a trade: it adds indirection and scatters one method’s logic across files. Choose by (how many sites switch on this type) × (how often new variants appear). The failure mode is a class hierarchy for a two-case, stable distinction switched on in one place — over-abstraction that costs reading and buys no flexibility. Polymorphise the switch that’s already hurting; leave the one that isn’t.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.