Skip to main content
CleanCodeMastery

Replace Conditional with Polymorphism: Give Every Kind Its Own Desk

Learn the Replace Conditional with Polymorphism refactoring with a school reception story, repeated type switches dissolving into subclasses, a factory in TypeScript and C#, and honest advice on when a plain switch is fine.

23 min read Updated June 11, 2026intermediate
refactoringpolymorphismswitch statementsinheritancetell don't asktypescriptcsharp

🏫 One rulebook versus five desks

Visit the reception of a big school in Pune on a Monday morning. All kinds of people walk in: parents wanting fee details, new admission seekers, book vendors, an inspector from the education board, and old students asking for transfer certificates.

In the old design, one receptionist — Mrs. Kulkarni — handles everyone with one giant rulebook. A visitor arrives; she asks, "What type are you?" Then she flips pages: if parent, go to page 12 for fee rules... if vendor, page 47 for the purchase procedure... if inspector, page 3, inform the principal immediately... Every task repeats this lookup. Issuing a pass? Flip pages by visitor type. Deciding who can meet whom? Flip pages by visitor type again. The same "what type are you?" question, asked again and again, with the answer scattered across one thick, fragile book.

Now the school grows. A new visitor type appears — say, an alumni donor. Someone must find every page in the rulebook where visitor types are listed and add the new case. Last term, the clerk who updated it missed page 47, and for two weeks every donor was asked to sign the vendor register and pay a vendor pass fee. One donor walked out. Mrs. Kulkarni is tired, and the rulebook has coffee stains.

The new principal, Mrs. Deshpande, redesigns the reception. Five desks now, one per visitor type: Parents' Desk, Admissions Desk, Vendors' Desk, Inspections Desk, Alumni Desk. Each desk has its own expert — Sneha runs Admissions, old Mr. Patil runs Vendors — and each desk knows its own job completely: its own pass rules, its own procedure, its own register. A guard at the entrance does exactly one thing: looks at the visitor once and points to the right desk. After that, nobody ever asks "what type are you?" again. Adding a donor desk touches nothing else — you place one new table.

This is today's refactoring. The giant rulebook is a method full of switch (visitorType). The desks are subclasses. The entrance guard is a factory. Replace Conditional with Polymorphism moves each branch of a type-based conditional into its own class, and lets the language's dynamic dispatch — not your switch — choose the behaviour.

Figure 1: A vendor's Monday at the new reception — routed once at the entrance, then served completely by one expert desk

🎯 What is Replace Conditional with Polymorphism?

The refactoring targets a specific pattern: a conditional that branches on a type code — a string, enum, or flag that names a kind of thing — where each branch computes that kind's version of some behaviour. One such switch is tolerable. The real trouble, as Fowler stresses, is that the same switch multiplies: every method that varies by kind repeats it. That is the Switch Statements smell in full bloom.

The cure replaces "ask the kind, then branch" with "let the object's class be the branch":

  1. Create one subclass per type-code value. ParentVisitor, VendorVisitor, InspectorVisitor — each desk gets built.
  2. Set up a factory so the type code is converted into the right subclass exactly once, at creation time. (If callers construct the base class directly, first apply Replace Constructor with Factory Method.)
  3. Move each switch, branch by branch, into overriding methods. The case Parent: body becomes ParentVisitor.issuePass(). The case Vendor: body becomes VendorVisitor.issuePass().
  4. Make the base method abstract (or leave a default for genuinely shared behaviour) once every branch has moved out.
  5. Delete the switches. Each call site becomes one polite line: visitor.issuePass().

The deep idea here has a name: Tell, Don't Ask. The old code asks the object about its type and decides on its behalf — Mrs. Kulkarni interrogating every visitor. The new code tells the object to do its job and trusts it to know how — the guard pointing to a desk that already knows everything. The decision moves to where the knowledge lives.

💡

One-line summary: Replace Conditional with Polymorphism moves each branch of a repeated type-switch into an overriding method on a subclass, so the runtime picks the right behaviour by the object's class — the switch is paid for once, in a factory, instead of on every call.

College corner: the machinery doing the magic is dynamic dispatch (also called late binding). When you call visitor.welcome() on a base-class reference, the compiler cannot know which body will run — that is decided at runtime by the object's actual class. In C++, C#, and Java this is typically implemented with a vtable: each class carries a hidden table of function pointers, one slot per virtual method, and the call becomes "jump through slot 2 of whatever table this object carries." JavaScript and Python achieve the same effect by walking the prototype chain or the MRO (method resolution order). The cost is one indirect jump — nanoseconds — and what you buy is enormous: the set of behaviours becomes open-ended. A switch is closed at compile time; a vtable slot can be filled by classes that did not exist when the caller was written. That openness is the formal content of the Open/Closed Principle: open for extension (new subclasses), closed for modification (no edits to existing call sites).

💡 When do we need it?

Strong signs that the rulebook should become desks:

  • The same switch appears in several methods. switch (type) in calculateFee(), again in passValidity(), again in welcomeMessage(). Three copies today, five next year. This is the textbook Switch Statements smell.
  • Adding a kind means a treasure hunt. Each new visitor type forces you to find and edit every switch. Miss one, ship a bug — remember the donors signing the vendor register. With subclasses, the compiler refuses to build until your new class implements every abstract method — completeness is enforced.
  • Understanding one kind requires grepping. "How does an inspector behave?" currently means searching for case Inspector across the file. After the refactoring, you open InspectorVisitor.ts and the whole answer is on one screen — one desk, one register.
  • Branches carry real behaviour, not just labels. Each case computes something different — fees, rules, messages. (If the code is only a label with no behaviour, a simple enum or Replace Type Code with Class is enough.)
  • The set of kinds is expected to grow. Growth is what makes the open-for-extension shape pay: new kind = new class, zero edits to existing code.

And the honest counter-signs:

  • One switch, one place, stable cases? Keep the switch. A hierarchy of five classes to remove a single ten-line conditional is ceremony, not design.
  • Variation over numeric ranges or data values (tax slabs, discount tiers) rather than stable kinds? A lookup table or Strategy object fits better than subclasses.
  • The kind changes at runtime (prepaid becomes postpaid)? An object cannot change its class. Reach for composition instead — see State and Strategy, and the Replace Type Code with State/Strategy post.

Before Mrs. Deshpande approved the desk plan, she asked the office to count where type-questions actually happened in the daily routine. The result convinced her — the same question was being re-asked in almost every task:

Figure 2: Where the what-type-are-you question hid in the old reception routine — pass issuing was only one of four repeat offenders

The two designs, side by side as the school experienced them:

Daily realityRulebook (switches)Desks (subclasses)
"How does an inspector behave?"Grep every case Inspector pageOpen one file, read one screen
Adding the donor kindEdit every switch; miss one, ship a bugAdd one class plus one factory line
Who asks "what type are you?"Every method, on every callThe entrance guard, exactly once
Forgetting a caseFound by a customer at runtimeRefused by the compiler at build time
Testing one kindSet up the type code, hit each switchInstantiate the subclass, test it alone

Place your own conditional on this map before reaching for subclasses:

Figure 3: The decision map — repeated switches over growing kinds want polymorphism; a single stable switch is fine as it is

Before and after at a glance

A compact version first. The school charges different visitor pass fees and prints different welcome lines:

// BEFORE: the rulebook — every method re-asks "what type are you?"
type VisitorType = "parent" | "vendor" | "inspector";
 
class Visitor {
  constructor(public type: VisitorType, public name: string) {}
 
  passFee(): number {
    switch (this.type) {
      case "parent": return 0;
      case "vendor": return 100;
      case "inspector": return 0;
    }
  }
 
  welcome(): string {
    switch (this.type) {        // the SAME switch, again
      case "parent": return `Welcome ${this.name}, please wait in the lounge.`;
      case "vendor": return `${this.name}, please sign the vendor register.`;
      case "inspector": return `Welcome ${this.name}. Informing the principal.`;
    }
  }
}
// AFTER: desks — each class knows its own job; one factory routes
abstract class Visitor {
  constructor(public name: string) {}
  abstract passFee(): number;
  abstract welcome(): string;
 
  static create(type: VisitorType, name: string): Visitor {
    switch (type) {            // the ONLY surviving switch
      case "parent": return new ParentVisitor(name);
      case "vendor": return new VendorVisitor(name);
      case "inspector": return new InspectorVisitor(name);
    }
  }
}
 
class ParentVisitor extends Visitor {
  passFee() { return 0; }
  welcome() { return `Welcome ${this.name}, please wait in the lounge.`; }
}
 
class VendorVisitor extends Visitor {
  passFee() { return 100; }
  welcome() { return `${this.name}, please sign the vendor register.`; }
}
 
class InspectorVisitor extends Visitor {
  passFee() { return 0; }
  welcome() { return `Welcome ${this.name}. Informing the principal.`; }
}

Call sites shrink from a switch to a sentence: visitor.welcome(). Everything a vendor does now lives in VendorVisitor — one desk, one file, one screen.

Figure 4: Before, one type code feeds the same switch in every method; after, a factory routes once and dispatch picks the desk

The runtime conversation makes the "route once, dispatch forever" rhythm visible:

Figure 5: One trip through the factory, then every later call is a direct tell — no type questions anywhere

🛠️ Step-by-step, the safe way

The danger in this refactoring is moving too much at once. Fowler's mechanics move one branch of one method at a time, with everything compiling and passing in between.

Step 1: Funnel creation through a factory. Before any behaviour moves, make sure no caller writes new Visitor("vendor", ...) directly. Add Visitor.create(...) and convert callers. Tests still pass — nothing else changed.

Step 2: Create empty subclasses. ParentVisitor, VendorVisitor, InspectorVisitor, all empty, all extending Visitor. Point the factory at them. Behaviour is still 100% in the base class switches; the subclasses are just name-tags so far. Test.

Step 3: Move one branch. Pick one method, say passFee(), and one kind, say vendor. Override in the subclass; keep the base switch as the fallback for everyone else:

// INTERMEDIATE: vendor's branch has moved; others still use the rulebook
class VendorVisitor extends Visitor {
  override passFee(): number { return 100; }   // moved!
}
 
class Visitor {
  passFee(): number {
    switch (this.type) {
      case "parent": return 0;
      // vendor case DELETED from here — the override handles it
      case "inspector": return 0;
      default: throw new Error(`No fee rule for ${this.type}`);
    }
  }
}

This half-moved state is completely safe: vendors hit the override, everyone else falls through to the old switch. Mr. Patil's desk has opened, while Mrs. Kulkarni still serves parents and inspectors from the rulebook. The reception never closes during renovation.

Step 4: Run the tests. Then move the next branch of the same method. Test. Then the next.

Step 5: Make the base method abstract. When every kind has its override, the base switch is dead code. Replace it with abstract passFee(): number; — now the compiler guarantees every current and future subclass implements it.

Step 6: Repeat for each switching method. welcome(), then the next, one at a time. When no behavioural switch remains outside the factory, consider deleting the type-code field itself; the class is the type now.

The migration itself is a small state machine, and every state in it is shippable:

Figure 6: Every intermediate state compiles and passes tests — you can stop and ship at any node
⚠️

Keep the code releasable after every step — that is the whole craft. The intermediate state (some branches moved, base switch still alive as fallback) must pass all tests, because real refactoring happens alongside other work, and you may need to ship mid-journey. Also resist the urge to "improve" branch logic while moving it. Move first, verify identical behaviour, improve later in a separate, visible step. Mixing a move with an edit is how silent behaviour changes slip into production.

🧪 A bigger real-life example

The school's accounts office calculates monthly fees and transport charges for three student categories: day scholars, hostellers, and scholarship students. Today both calculations switch on a string:

// BEFORE: two methods, the same three-way switch in each
class Student {
  constructor(
    public category: "day-scholar" | "hosteller" | "scholarship",
    public distanceKm: number,
  ) {}
 
  monthlyFee(): number {
    switch (this.category) {
      case "day-scholar": return 2500;
      case "hosteller": return 2500 + 4000;          // fee + mess
      case "scholarship": return 0;
      default: throw new Error("Unknown category");
    }
  }
 
  transportCharge(): number {
    switch (this.category) {
      case "day-scholar": return this.distanceKm * 60;
      case "hosteller": return 0;                     // lives on campus
      case "scholarship": return Math.min(this.distanceKm * 60, 500); // capped
      default: throw new Error("Unknown category");
    }
  }
}

After the refactoring, each category becomes a desk that knows its own arithmetic:

// AFTER
abstract class Student {
  constructor(public distanceKm: number) {}
  abstract monthlyFee(): number;
  abstract transportCharge(): number;
 
  // one place in the whole system that maps code -> class
  static create(category: string, distanceKm: number): Student {
    switch (category) {
      case "day-scholar": return new DayScholar(distanceKm);
      case "hosteller": return new Hosteller(distanceKm);
      case "scholarship": return new ScholarshipStudent(distanceKm);
      default: throw new Error(`Unknown category: ${category}`);
    }
  }
}
 
class DayScholar extends Student {
  monthlyFee() { return 2500; }
  transportCharge() { return this.distanceKm * 60; }
}
 
class Hosteller extends Student {
  monthlyFee() { return 2500 + 4000; }
  transportCharge() { return 0; }
}
 
class ScholarshipStudent extends Student {
  monthlyFee() { return 0; }
  transportCharge() { return Math.min(this.distanceKm * 60, 500); }
}

Now run the growth test. A new category arrives: NCC cadets get full fees but free transport. In the before-world you edit two switches (and the third one someone added last month that you do not know about). In the after-world you write one new class:

class NccCadet extends Student {
  monthlyFee() { return 2500; }
  transportCharge() { return 0; }
}
// plus one new line in the factory — and nothing else changes

If you forget to implement transportCharge, the code does not even compile. The "forgot a case in one of the switches" bug class is gone — the compiler took over that worry. This open-for-extension shape, where new behaviour is added rather than edited in, is the practical face of the Open/Closed Principle.

Figure 7: The student hierarchy — each category gathers its whole behaviour; the factory is the single door where the code string enters

Watch how the cost of growth diverges between the two designs. With switches, every new category must be edited into every switching method, and the count of switching methods itself tends to grow. With subclasses, the cost per new kind is constant — one class, one factory line:

Figure 8: Edits required per new kind as the codebase grows — the switch design climbs, the subclass design stays flat

The rising line is the rulebook: one edit per switching method, forever. The flat line is the desk design: one new class plus one factory line, no matter how many behaviours exist. The lines cross almost immediately — which is why Fowler treats repetition of the switch as the trigger, not the mere existence of one.

The same refactoring in C#

C# makes the pattern crisp with abstract classes, expression-bodied members, and a switch expression for the one legal switch — the factory:

public abstract class Student
{
    public double DistanceKm { get; }
    protected Student(double distanceKm) => DistanceKm = distanceKm;
 
    public abstract decimal MonthlyFee();
    public abstract decimal TransportCharge();
 
    public static Student Create(string category, double km) => category switch
    {
        "day-scholar" => new DayScholar(km),
        "hosteller"   => new Hosteller(km),
        "scholarship" => new ScholarshipStudent(km),
        _ => throw new ArgumentOutOfRangeException(nameof(category))
    };
}
 
public sealed class DayScholar : Student
{
    public DayScholar(double km) : base(km) { }
    public override decimal MonthlyFee() => 2500m;
    public override decimal TransportCharge() => (decimal)DistanceKm * 60m;
}
 
public sealed class Hosteller : Student
{
    public Hosteller(double km) : base(km) { }
    public override decimal MonthlyFee() => 2500m + 4000m;
    public override decimal TransportCharge() => 0m;
}
 
public sealed class ScholarshipStudent : Student
{
    public ScholarshipStudent(double km) : base(km) { }
    public override decimal MonthlyFee() => 0m;
    public override decimal TransportCharge() =>
        Math.Min((decimal)DistanceKm * 60m, 500m);
}

C#-specific notes worth keeping:

  • sealed the leaves. Marking concrete subclasses sealed tells readers the hierarchy is one level deep by design and lets the runtime devirtualize calls.
  • Switch expressions are not polymorphism. Modern pattern matching (student switch { Hosteller h => ..., ... }) is wonderful at boundaries — serialization, display formatting — but if behavioural switches over your own subclasses creep back into core logic, the rulebook is quietly regrowing.
  • An interface works too. If the kinds share no data or implementation, interface IStudentCategory with three implementing classes is lighter than an abstract base. Choose abstract class when there is shared state (like DistanceKm); choose interface when there is only a contract.
  • Dependency injection can replace the factory. In ASP.NET applications the mapping from code to class often lives in DI registrations or a keyed-service lookup — same single-door idea, framework-managed.

College corner: when you study virtual calls at the machine level, you will meet the trade-offs engine and compiler authors lose sleep over. A virtual call through a vtable defeats inlining and branch prediction in ways a hot switch sometimes does not, which is why JIT compilers perform devirtualization — proving at runtime that only one class ever arrives at a call site and replacing the indirect jump with a direct, inlinable one (C#'s sealed and the JVM's class-hierarchy analysis both feed this). The practical takeaway for coursework and interviews: choose between switch and polymorphism on maintainability grounds, because at typical application scale the performance difference is noise, and the JIT is busy erasing even that.

IDE support

No IDE performs the whole transformation in one click — it is a composite refactoring — but the building blocks are well automated:

  • JetBrains Rider / IntelliJ IDEA / ReSharper: the Push Members Down refactoring moves a method from base to subclasses (creating per-subclass copies you then trim to one branch each), and Extract Superclass / Extract Interface builds the hierarchy. Inspections flag repeated switches over the same value and offer Replace 'switch' with polymorphic calls style intentions in some languages.
  • Visual Studio: Ctrl+. quick actions cover the pieces — Extract base class, Pull members up, Generate overrides (which writes the override stubs for every abstract member in one go — lovely for Step 5), and Convert switch statement to switch expression for the surviving factory.
  • VS Code with C#/TypeScript language services offers extract-interface and implement-abstract-class code actions that generate the subclass skeletons.

The judgement calls — which branches are kinds, what the abstract surface should be — remain human work. The IDE just makes each mechanical step typo-free. Mrs. Deshpande decided which desks to build; the carpenter only built them straight.

⚠️ Benefits and risks

BenefitsRisks / costs
Each kind's whole behaviour lives in one class — readable and testable as a unitA class hierarchy for a single, stable switch is over-engineering
Adding a kind = one new subclass; compiler enforces every abstract methodMore types and files; indirection makes "where does this run?" one hop harder
Repeated switches collapse to one factory mapping — single door for the type codeWrong axis of variation (numeric ranges, runtime-changing kinds) makes subclasses awkward
Kills the "forgot a case in one switch" bug class permanentlyHierarchies are rigid: two independent varying dimensions explode into N×M subclasses — prefer composition then
Open for extension: growth happens by adding code, not editing itShared behaviour must be managed carefully in the base; duplication can creep into siblings
Natural gateway to State and Strategy patternsHalf-finished migration (some switches left behind) is worse than either pure state

Which smells does it cure?

SmellHow this refactoring helps
Switch StatementsThe defining cure — repeated type-switches dissolve into dynamic dispatch
Shotgun SurgeryAdding a kind no longer means editing every switching method; it is one new class
Primitive ObsessionThe string/enum type code stops driving logic; real classes carry the behaviour
Long MethodMethods stuffed with multi-branch switches shrink to one delegating line
Duplicated CodeThe same case-ladder copy-pasted across methods is written exactly once — as a class structure

The whole post in one revision picture:

Figure 9: Replace Conditional with Polymorphism at a glance — trigger, mechanics, result, and its boundaries

Quick revision box

+----------------------------------------------------------------+
|   REPLACE CONDITIONAL WITH POLYMORPHISM - REVISION CARD        |
+----------------------------------------------------------------+
| Problem  : same switch on a TYPE CODE repeated across methods  |
|            new kind => edit every switch (miss one = bug)      |
| Solution : one SUBCLASS per kind; each branch becomes an       |
|            OVERRIDE; a FACTORY maps code -> class ONCE;        |
|            call sites become plain method calls                |
| Result   : "ask kind, then branch"  ->  "tell object, it knows"|
|                                                                |
| MECHANICS: factory -> empty subclasses -> move ONE branch      |
|            -> test -> repeat -> make base method abstract      |
| THE ONE SURVIVING SWITCH lives in the factory. That is fine.   |
| SKIP IT WHEN: one switch, one place, stable cases              |
| WRONG AXIS : kind changes at runtime -> State/Strategy instead |
+----------------------------------------------------------------+

Practice exercise

A courier company calculates delivery for three service types, and the switch has already been copy-pasted into two methods:

class Shipment {
  constructor(
    public service: "standard" | "express" | "same-day",
    public weightKg: number,
  ) {}
 
  price(): number {
    switch (this.service) {
      case "standard": return 40 + this.weightKg * 10;
      case "express": return 90 + this.weightKg * 18;
      case "same-day": return 250 + this.weightKg * 30;
      default: throw new Error("Unknown service");
    }
  }
 
  promiseDays(): number {
    switch (this.service) {     // same ladder again
      case "standard": return 5;
      case "express": return 2;
      case "same-day": return 0;
      default: throw new Error("Unknown service");
    }
  }
}

Refactor it step by step:

  1. Add a static factory Shipment.create(service, weightKg) and convert all callers (write a couple if this is a fresh playground). Tests green?
  2. Create three empty subclasses — StandardShipment, ExpressShipment, SameDayShipment — and route the factory to them. Behaviour unchanged; tests green?
  3. Move price() one branch at a time: override in ExpressShipment first, delete only that case from the base switch, test, then the others.
  4. When all three overrides exist, make price() abstract on the base. Repeat the whole dance for promiseDays().
  5. Now the growth test: marketing launches "economy" — ₹25 + ₹6/kg, 8-day promise. Prove you added one class and one factory line, and that forgetting promiseDays() would not even compile.
  6. Bonus thinking: the company adds fragile-item handling that varies independently of service type. Would you create FragileExpressShipment, FragileStandardShipment... or compose a separate handling object? One sentence, using what you know about subclass explosion.
  7. College bonus: sketch (on paper) the vtable for ExpressShipment after step 4. Which slots does it own, and what exactly does the runtime do when price() is called through a Shipment reference?

If your answer to question 6 was "compose — two independent dimensions should be two objects, not N×M subclasses," you have understood not just this refactoring but its boundary, which is the deeper lesson. Mrs. Deshpande would hire you for the front office. Well done.

Frequently asked questions

Is every switch statement bad?
No. A single switch in a single place, especially over a small stable set of cases, is simple and honest. The smell appears when the SAME switch over the same type code repeats in many methods, or when new kinds keep arriving. Polymorphism pays its class-hierarchy cost only when the conditional is duplicated or growing.
Where does the switch go after the refactoring? Does it fully disappear?
One switch usually survives, on purpose, inside a factory. The factory maps the incoming type code (a string from the database, an enum from an API) to the right subclass exactly once. Every other switch in the codebase collapses into a plain method call that dynamic dispatch resolves.
What is 'Tell, Don't Ask' and how does it relate here?
Before the refactoring, code ASKS the object what kind it is and then branches: 'are you a day-scholar? then do X.' After it, code TELLS the object what to do — visitor.handle() — and the object's own class decides how. Moving the decision inside the object is the heart of object-oriented design.
How is this different from Replace Type Code with Subclasses?
They are partners. Replace Type Code with Subclasses builds the class hierarchy from a type-code field. Replace Conditional with Polymorphism then moves each switching method's branches into those subclasses. In practice you often perform them together: first create the kinds, then relocate the behaviour.
What if the kind of an object can change while it lives?
Then subclassing the object itself is wrong, because an object's class is fixed at construction. Use composition instead: move the varying behaviour into a swappable State or Strategy object. Our Replace Type Code with State/Strategy post covers exactly that situation.

Further reading

Related Lessons