Type switches & refused bequest
Two OO smells. A type-switch repeated in N places grows with every variant — the strongest signal for replace-conditional-with-polymorphism. Refused bequest, where a subclass no-ops inherited methods, means the is-a was false; cure it with delegation.
You add a fourth notification channel — Slack — and the compiler walks you through the codebase like a tour guide: a switch (type) in the sender, another in the retry logic, a third in the analytics tagger, a fourth in the formatter. Four files, the same shape, all keyed on the same string. The feature was “one new case”; the edit was everywhere. Separately, you notice ReadOnlyList extends ArrayList whose add() throws — a class that inherited a method it spends its whole existence refusing.
Both are smells with names, and both point at the same root mistake: behaviour that varies by type was modelled with the wrong mechanism. The type-switch scatters one decision across the codebase; refused bequest forces a child to live inside a parent it doesn’t actually fit. This lesson is how to recognise each — and, just as importantly, when not to “fix” them.
After this lesson you can recognise a repeated type-switch as the strongest practical trigger for replace-conditional-with-polymorphism, and explain why it is an open/closed failure rather than a style nit; identify refused bequest (a subclass that no-ops, throws on, or hollows out inherited methods) as evidence the is-a relationship is false; reach for replace-inheritance-with-delegation as its cure; and state the failure mode for both — turning a single stable switch into a class hierarchy is over-engineering, not a fix.
A type-switch is a smell only once it repeats; the count of duplicate switches is the signal, not the switch itself. One switch on a tag, in one place, is just a function. The smell is the same switch — same cases, same discriminant — appearing in three, four, five places. Now every new variant is not one edit but N edits, and the compiler can’t even warn you about the ones you forget if the default branch silently does nothing.
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) { case "circle": return Math.PI * s.r ** 2; /* ... */ }
}
function perimeter(s: Shape): number {
switch (s.kind) { case "circle": return 2 * Math.PI * s.r; /* ... */ }
}
function describe(s: Shape): string {
switch (s.kind) { case "circle": return `circle r=${s.r}`; /* ... */ }
}Add a triangle and you touch area, perimeter, describe, and every other function with that switch. The knowledge “what kinds exist” is duplicated across all of them.
The repeated type-switch is an Open/Closed failure: adding a variant forces edits to existing, working code. OCP says a module should be open to extension but closed to modification — you should be able to add a triangle without reopening area. The scattered switch makes that impossible: the new case must be wedged into every function that dispatches on kind. Replace-conditional-with-polymorphism inverts this. Move each per-case branch onto the type, so the cases of one switch become the methods of one object, and dispatch becomes the language’s virtual call.
interface Shape {
area(): number;
perimeter(): number;
describe(): string;
}
class Circle implements Shape {
constructor(private r: number) {}
area() { return Math.PI * this.r ** 2; }
perimeter() { return 2 * Math.PI * this.r; }
describe() { return `circle r=${this.r}`; }
}Adding Triangle is now one new class implementing the interface — the compiler forces you to supply every method, and no existing class is reopened. The N switches collapsed into N methods in one place per type.
Refused bequest: a subclass inherits methods it doesn’t want, and no-ops, throws on, or hollows them out — proof the is-a relationship is false. Inheritance is a promise: a Penguin is-a Bird, so anywhere code expects a Bird a Penguin works. When a subclass has to override an inherited method to do nothing, throw NotSupported, or return a meaningless value, it is refusing the bequest — telling you it isn’t really that thing.
class Bird {
fly(): void { /* move through the air */ }
layEgg(): Egg { /* ... */ }
}
class Penguin extends Bird {
fly(): void {
throw new Error("penguins can't fly"); // refused bequest
}
}Penguin inherited fly() only to disown it. Every caller that holds a Bird and calls fly() is now a latent crash for penguins — a Liskov violation in the making. The override-to-throw is the smell; the false extends is the disease.
Cure refused bequest with replace-inheritance-with-delegation: keep what you genuinely reuse, drop the false is-a. If a class needs some of a parent’s behaviour but not its type, hold the would-be parent as a field and delegate to it, exposing only the methods that actually apply. The class stops being a Bird and starts having the laying behaviour it wants — composition replaces a lie with a fact.
class Penguin {
private layer = new EggLaying(); // reuse without inheriting
layEgg(): Egg { return this.layer.layEgg(); }
swim(): void { /* what penguins actually do */ }
// no fly() exists to refuse — the interface is honest
}Now no caller can ask a Penguin to fly(), because Penguin never claimed to be a Bird. The bequest isn’t refused; it was never accepted. Reach for an interface (EggLayer) if several types share the contract, and delegate the implementation.
Before — one type-switch, copied across four files. A payments service dispatches on method everywhere:
// charge.ts
function charge(p: Payment) {
switch (p.method) {
case "card": return chargeCard(p);
case "paypal": return chargePaypal(p);
case "wire": return chargeWire(p);
}
}
// fees.ts
function feeFor(p: Payment) {
switch (p.method) { case "card": return 0.029; case "paypal": return 0.034; case "wire": return 0; }
}
// settlement.ts, refunds.ts — the same switch, two more timesAdding crypto means editing charge, feeFor, settlement, and refunds — four working files reopened for one new variant, and a missed default branch silently returns undefined.
After — replace the conditional with polymorphism. Each method becomes a strategy implementing one interface:
interface PaymentMethod {
charge(p: Payment): ChargeResult;
fee(): number;
}
class Card implements PaymentMethod {
charge(p: Payment) { /* ... */ }
fee() { return 0.029; }
}
class Crypto implements PaymentMethod { // the new variant: ONE place
charge(p: Payment) { /* ... */ }
fee() { return 0.01; }
}
const methods: Record<string, PaymentMethod> = { card: new Card(), /* ... */ crypto: new Crypto() };charge, feeFor, settlement, and refunds now call methods[p.method].charge(...) / .fee(). Adding Crypto is a single new class plus a registry entry; the four call sites never change, and the interface forces every method to be implemented — the compiler closes the gap the silent default left open.
▸Why this works
Why is the repeated switch — not the single one — the strongest signal for polymorphism? Because polymorphism’s whole payoff is collapsing duplicated dispatch into one mechanism. A switch that appears once has nothing to collapse: the class hierarchy you’d introduce is pure overhead, scattering one readable function across several files you must now jump between to understand. When the same discriminant drives branching in many places, each new variant taxes all of them, and that tax is what polymorphism removes. The senior read is mechanical: count the switches on the same tag. One, localised, stable → leave it. Three-plus, or one that keeps growing a case per sprint → that’s the trigger.
▸Common mistake
The over-correction: converting a single, localized, stable switch into a class hierarchy because “switch statements are a smell.” A two-case switch in one function that hasn’t gained a case in a year is not change-hostile — replacing it with an interface, two classes, and a factory adds indirection, files, and reading cost while buying flexibility no incoming change needs. That’s over-engineering wearing a pattern’s name. Likewise, don’t reach for delegation to “purify” an inheritance relationship that is genuinely an is-a and whose subclasses honour every inherited method — the bequest isn’t refused, so there’s nothing to cure. Both refactorings are answers to evidence (duplicate switches; methods that throw), not to the mere presence of a switch or an extends.
A code review shows a single switch on order.status with three cases, in one reporting function, unchanged for over a year. A reviewer flags it: 'switch statements are a smell — replace it with polymorphism.' What is the senior call?
Two object-oriented smells share one root: behaviour that varies by type modelled with the wrong mechanism. A type-switch becomes a smell once it repeats — the same discriminant driving branching across many sites — because each new variant then forces edits everywhere, which is an open/closed failure; the cure is replace-conditional-with-polymorphism, collapsing the N switches into methods on each type so a new variant is one class. Refused bequest — a subclass that throws on, no-ops, or hollows out inherited methods — is evidence the is-a relationship is false; the cure is replace-inheritance-with-delegation, holding the would-be parent as a field and exposing only the methods that honestly apply. The senior discipline is to act on evidence, not on syntax: count the duplicate switches, watch for overrides that disown their parent — and resist converting a single stable switch into a hierarchy, which trades real simplicity for flexibility no change is asking for.
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.