skip to content

When should you apply "Replace Conditional with Polymorphism", how do you do it in safe steps, and when is it the wrong call?

level: seniorimportance: should knowfreq 55%

answer

  1. Trigger = same case list switched in many places
  2. Factory first, then migrate one branch at a time
  3. Base keeps default; delete conditional last
  4. Wrong for one local switch, data-only variation, two axes
  5. Expression problem: variants cheap vs operations cheap

basics

~20 s

When the same switch on a type code appears in several places, create one subclass (or strategy object) per case, move each branch's body into the matching subclass, and let the language dispatch. Adding a new case then means adding a class instead of editing every switch.

solid answer

~60 s

Apply it when the *same* set of cases is switched on repeatedly across the codebase — that is the shotgun-surgery pain the refactoring removes. Safe mechanics: ensure a factory or creation point can produce the right variant; create a subclass or strategy per case; move one conditional at a time via Extract Function then Move Function/Push Down Method into the variants, replacing that branch with a plain call; run tests after each; only when every branch has migrated do you delete the conditional and the type code. Behaviour is preserved throughout because the base implementation stays until the last variant overrides it. It is the wrong call for a single, local, stable conditional — one `switch` in one place is clearer than five classes and a factory. It is also wrong when the variation is on data rather than type, when cases combine along two independent axes (you get a class explosion; prefer composition or a table), or when the language has exhaustive pattern matching on a sealed/closed type, which gives compile-time totality checking that polymorphism does not.

code

pseudocode · 12 lines
pseudocode
// before: the same case list repeated in every function
function plumage(b) { switch (b.type) { case A: ...; case B: ...; case C: ... } }
function airSpeed(b){ switch (b.type) { case A: ...; case B: ...; case C: ... } }

// after: one class per case, dispatch by the language
class Bird       { plumage() { return "average" }  airSpeed() { return 35 } }
class Norwegian extends Bird { plumage() { return this.voltage > 100 ? "scorched" : "beautiful" }
                               airSpeed() { return 0 } }

function createBird(data) {          // the single creation seam you migrate first
  switch (data.type) { case "norwegian": return new Norwegian(data); default: return new Bird(data) }
}

go deeper

for a junior

Explain the shape: one class per case, each implementing the method its own way, and the language picks the right one so the switch disappears.

for a middle

Identify the trigger as the same case list repeated across several functions, and give the incremental mechanics — factory first, then move one branch at a time into a subclass while the rest still switch.

for a senior

Add the counter-indications (single local conditional, data-only variation, two axes of variation, anaemic subclasses), mention the strategy/composition alternative, and note that one dispatch remains at the creation boundary.

for a principal

Frame it via Open–Closed and the expression problem — polymorphism optimises for adding variants, closed sums with exhaustive matching for adding operations — and discuss migrating a large codebase in green, reviewable steps plus the reverse move (Replace Subclass with Delegate) when the axis proves wrong.

### The smell You see a conditional that branches on something identifying a *kind of thing* — a type code, an enum, a string discriminator, a class check: ``` function plumage(bird) { switch (bird.type) { case "EuropeanSwallow": return "average" case "AfricanSwallow": return bird.coconuts > 2 ? "tired" : "average" case "NorwegianBlue": return bird.voltage > 100 ? "scorched" : "beautiful" } } function airSpeed(bird) { switch (bird.type) { /* the same three cases again */ } } function canFly(bird) { switch (bird.type) { /* and again */ } } ``` The defining pain is **not** the conditional itself — it is that the *same case list is repeated*. Adding a new bird type means hunting down every switch. That is the **Shotgun Surgery** smell, and it is the actual trigger. This is also what the **Open–Closed Principle** targets: the system should be open to new variants without editing existing code. ### The refactoring Give each case its own class; each class overrides the varying methods; the language's dynamic dispatch does the branching. All the knowledge about one variant ends up in one file. **Safe, incremental mechanics:** 1. **Establish creation.** You need one place that turns the type code into the right object — a factory function/method. If objects are already created all over the place, introduce the factory first (a preparatory refactoring) so there is exactly one seam to change. 2. **Create the class hierarchy.** A base class holding the default/most-common behaviour, plus one subclass per case. (Or, if you prefer composition, one strategy object per case injected into a single class — same idea, no inheritance; often preferable because it avoids locking the object's identity to one axis of variation.) 3. **Migrate one method, one branch at a time.** Extract the branch body into a function; move it into the corresponding subclass as an override; delete that `case` from the conditional. The remaining cases still run through the conditional in the base implementation. Run the tests. 4. **Repeat** until the conditional has one branch left — that becomes the base-class behaviour — then delete the conditional entirely. 5. **Delete the type code field** once nothing reads it. The crucial property is that at every intermediate step the system is fully working: some cases dispatch, the rest still switch. This is what makes the refactoring safe on a large codebase and reviewable commit by commit. ### A second, distinct use: the variation-of-base-behaviour case Beyond type codes, this refactoring also applies when a method computes a base result and then a conditional applies case-specific adjustments. Push the base logic into a base class or template method and let subclasses override the varying hooks. That is the **Template Method** shape and it is the same transformation viewed from a different angle. ### When it is the WRONG call 1. **A single, local, stable conditional.** Three lines of `switch` used in exactly one function is more readable than a base class, three subclasses, a factory and the indirection tax. Conditionals are fine; polymorphism buys you something only when the same dispatch repeats or is likely to grow. 2. **Variation on data, not on type.** If the branches differ only in constants (rate, threshold, message), the right fix is a lookup table or configuration, not a class per row. 3. **Two independent axes of variation.** Cases like {domestic, international} × {standard, express} produce a combinatorial explosion of subclasses. Use composition (a strategy per axis), a decision table, or double dispatch instead. 4. **Exhaustive pattern matching over a closed/sealed type.** Many languages can prove at compile time that every case is handled, and adding a variant produces a compile error at each site. That gives *better* safety than polymorphism for the "add a case" direction, and keeps related logic visible together. The classic trade-off — the **expression problem** — is: polymorphism makes adding *variants* cheap and adding *operations* expensive (every subclass must change); closed sums with pattern matching make adding *operations* cheap and adding *variants* a compiler-guided edit across every match. Choose based on which axis actually changes in your system. Being able to state this trade-off is the senior/principal differentiator. 5. **Behaviour that belongs to the caller, not the object.** If the branch decides *what the caller wants to do*, moving it into the object can invert the dependency wrongly and drag presentation or transport concerns into the domain model. 6. **Anaemic subclasses.** If each subclass overrides one method returning a constant, you have written a lookup table with extra ceremony. ### Related catalog entries - **Replace Type Code with Subclasses** — the enabling step that gives you the hierarchy in the first place. - **Introduce Special Case / Null Object** — for the specific case where the repeated conditional is a null/absent check; give absence its own object with harmless behaviour. - **Replace Subclass with Delegate / Replace Superclass with Delegate** — the *reverse* direction, used when the hierarchy proved to be the wrong axis of variation. The catalog runs both ways deliberately, and expecting to reverse the decision later is a mark of maturity, not failure. - **Decompose Conditional / Consolidate Conditional Expression** — cheaper first moves that often make the polymorphism question moot.

  • You replace the switch with subclasses, but one `switch (bird.type)` survives in the factory. Is the refactoring a failure?
    No — that is the intended end state. The dispatch has been reduced from N scattered switches to exactly one, at the creation boundary where the raw data (a database row, a JSON payload) is turned into an object. Data has to become a type somewhere; the win is that it happens once.
  • When is exhaustive pattern matching on a sealed type preferable to a class hierarchy?
    When you add new operations more often than new variants, and when compile-time exhaustiveness matters. A sealed sum makes adding a variant a compiler-guided edit at every match site — noisy but safe — while keeping all cases of one operation readable side by side. Polymorphism inverts that trade-off: cheap new variants, but every new operation touches every subclass. That is the expression problem.
  • How do you keep the system working while migrating?
    Migrate one branch at a time: extract the branch body, move it into the matching subclass as an override, delete just that case. Unmigrated cases keep flowing through the remaining conditional in the base implementation, so the build stays green after every commit and the change is reviewable in slices.

Instead of a receptionist with a laminated card listing what to do for each type of visitor — a card that must be reprinted at every desk whenever a new visitor type appears — you give each visitor type its own badge that already knows the routine. New visitor type: print one badge, reprint no cards.

saying these in an interview costs you the question

  • "Conditionals are bad / every switch should be polymorphism" — a single local conditional is usually the clearest option; the trigger is repeated dispatch on the same case list.
  • Assuming the refactoring eliminates every conditional; one factory switch at the creation boundary is the expected result.
  • Creating a subclass per case when the cases differ only in constant values — that is a lookup table wearing class costumes.
  • Modelling two independent axes of variation with one inheritance hierarchy, producing a combinatorial class explosion.
  • Doing it as one big-bang rewrite rather than migrating branch by branch with tests green in between.
  • Not knowing that Replace Subclass with Delegate exists to undo the hierarchy when the chosen axis turns out wrong.

context