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.
🏫 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.
🎯 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":
- Create one subclass per type-code value.
ParentVisitor,VendorVisitor,InspectorVisitor— each desk gets built. - 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.)
- Move each switch, branch by branch, into overriding methods. The
case Parent:body becomesParentVisitor.issuePass(). Thecase Vendor:body becomesVendorVisitor.issuePass(). - Make the base method abstract (or leave a default for genuinely shared behaviour) once every branch has moved out.
- 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)incalculateFee(), again inpassValidity(), again inwelcomeMessage(). 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 Inspectoracross the file. After the refactoring, you openInspectorVisitor.tsand 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:
The two designs, side by side as the school experienced them:
| Daily reality | Rulebook (switches) | Desks (subclasses) |
|---|---|---|
| "How does an inspector behave?" | Grep every case Inspector page | Open one file, read one screen |
| Adding the donor kind | Edit every switch; miss one, ship a bug | Add one class plus one factory line |
| Who asks "what type are you?" | Every method, on every call | The entrance guard, exactly once |
| Forgetting a case | Found by a customer at runtime | Refused by the compiler at build time |
| Testing one kind | Set up the type code, hit each switch | Instantiate the subclass, test it alone |
Place your own conditional on this map before reaching for subclasses:
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.
The runtime conversation makes the "route once, dispatch forever" rhythm visible:
🛠️ 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:
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 changesIf 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.
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:
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:
sealedthe leaves. Marking concrete subclassessealedtells 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 IStudentCategorywith three implementing classes is lighter than an abstract base. Choose abstract class when there is shared state (likeDistanceKm); 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 theoverridestubs 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
| Benefits | Risks / costs |
|---|---|
| Each kind's whole behaviour lives in one class — readable and testable as a unit | A class hierarchy for a single, stable switch is over-engineering |
| Adding a kind = one new subclass; compiler enforces every abstract method | More types and files; indirection makes "where does this run?" one hop harder |
| Repeated switches collapse to one factory mapping — single door for the type code | Wrong axis of variation (numeric ranges, runtime-changing kinds) makes subclasses awkward |
| Kills the "forgot a case in one switch" bug class permanently | Hierarchies are rigid: two independent varying dimensions explode into N×M subclasses — prefer composition then |
| Open for extension: growth happens by adding code, not editing it | Shared behaviour must be managed carefully in the base; duplication can creep into siblings |
| Natural gateway to State and Strategy patterns | Half-finished migration (some switches left behind) is worse than either pure state |
Which smells does it cure?
| Smell | How this refactoring helps |
|---|---|
| Switch Statements | The defining cure — repeated type-switches dissolve into dynamic dispatch |
| Shotgun Surgery | Adding a kind no longer means editing every switching method; it is one new class |
| Primitive Obsession | The string/enum type code stops driving logic; real classes carry the behaviour |
| Long Method | Methods stuffed with multi-branch switches shrink to one delegating line |
| Duplicated Code | The same case-ladder copy-pasted across methods is written exactly once — as a class structure |
The whole post in one revision picture:
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:
- Add a static factory
Shipment.create(service, weightKg)and convert all callers (write a couple if this is a fresh playground). Tests green? - Create three empty subclasses —
StandardShipment,ExpressShipment,SameDayShipment— and route the factory to them. Behaviour unchanged; tests green? - Move
price()one branch at a time: override inExpressShipmentfirst, delete only that case from the base switch, test, then the others. - When all three overrides exist, make
price()abstract on the base. Repeat the whole dance forpromiseDays(). - 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. - 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. - College bonus: sketch (on paper) the vtable for
ExpressShipmentafter step 4. Which slots does it own, and what exactly does the runtime do whenprice()is called through aShipmentreference?
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
Switch Statements: The Receptionist With the Giant Rulebook
Learn the Switch Statements code smell with a school receptionist story, duplicated switch examples in TypeScript and C#, and the polymorphism cure.
State Pattern: The Fan That Changes Its Mood
Learn the State design pattern with a ceiling fan regulator story, simple TypeScript and C# code, state diagrams, real software examples, and practice.
Strategy Pattern: Cycle, Bus, or Auto — You Choose
Learn the Strategy design pattern with a simple school travel story, easy TypeScript and C# code, runtime swapping, real examples, and practice tasks.
Replace Type Code with Subclasses: When Each Kind Truly Behaves Differently
Learn the Replace Type Code with Subclasses refactoring with a day-scholar/boarder/hosteller story, switch-removal in TypeScript and C#, and the decision guide for Class vs Subclasses vs State/Strategy.