Skip to main content
CleanCodeMastery

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.

26 min read Updated June 11, 2026beginner
refactoringtype codesubclassespolymorphismswitch statementstypescriptcsharp

🏔️ Three kinds of students, one tired clerk

Let me take you to a residential school near Dehradun. Its admission register has a column called "Student Type", and the office clerk, Mr. Joshi, writes a single letter in it: D, B, or H.

  • D means Day-scholar — comes at 8 a.m., leaves at 3 p.m., pays tuition fee only, eats lunch from a tiffin brought from home.
  • B means Boarder — stays in school Monday to Friday, goes home on weekends, pays tuition plus a five-day mess fee.
  • H means Hosteller — lives in the hostel full-time, pays tuition plus full mess fee plus hostel fee, and needs a gate pass even to visit the market.

Now watch the school at work, because three different people consult that one letter every single day. When fee bills are printed, Mr. Joshi checks the letter: D gets one formula, B another, H a third. When the 3 p.m. bell rings, Bahadur, the gatekeeper, checks the letter on the student's ID card: D walks out, B and H stay. When dinner is planned, Mrs. Negi, the mess manager, checks the letter: count the H students fully, the B students for weekdays, the D students not at all.

Do you see it? The same three-way check is performed by the clerk, the gatekeeper, and the mess manager — each keeping their own private copy of the rules, in their own head, in their own notebook. Three copies of one rulebook. What could go wrong?

Last term, the answer arrived. The school introduced a new type, W, for weekly boarders with Saturday classes. Mr. Joshi updated the fee rules in his ledger. Mrs. Negi updated the dinner count in her kitchen book. But nobody told Bahadur. For two weeks, confused W-students were stopped at the gate at 3 p.m. with no rule to cover them. One boy, Kabir, missed his bus home four days in a row before his father came shouting to the principal's office. The principal asked the obvious question: "How many other notebooks have these rules?" Nobody could give a confident number. That is the scariest answer in software too.

Here is the key observation, and please hold it tightly: a day-scholar and a hosteller are not just labelled differently — they genuinely behave differently. Different fees. Different timings. Different gate rules. And one more thing: in this school, a student's type is settled at admission. The register entry for a D-student never quietly becomes an H-entry; if a family shifts a child into the hostel, the office closes the old record and opens a fresh admission. The type never changes on the same record.

When a type code drives real behaviour, and the type is fixed for the object's whole life, the right refactoring is Replace Type Code with Subclasses: give each kind its own class, and let each class carry its own rules.

Figure 1: Where the same D/B/H ladder secretly lived — three departments, three private copies

And here is how the week of type W felt to the people inside it — first in the switch-ladder world, then in the subclass world after the refactoring.

Figure 2: The week type W arrived — switch ladders versus subclasses, as lived by the staff

🔍 What is Replace Type Code with Subclasses?

In the Replace Type Code with Class post, we met type codes that were pure labels — school houses where nothing behaves differently per house. A value class was enough there.

This post handles the heavier case. The field still holds a type code ('D' | 'B' | 'H', or 0/1/2), but now the code is consulted inside methods to make the object behave differently per type. Every such method grows a switch or an if-else-if ladder over the same codes. The fee method switches. The gate method switches. The mess method switches. This is the Switch Statements smell in full bloom: the same branching, copy-pasted, drifting out of sync exactly like the three staff notebooks.

Replace Type Code with Subclasses says:

  1. Make the host class abstract (or give it an interface).
  2. Create one subclass per type code: DayScholar, Boarder, Hosteller.
  3. Move each switch branch into the matching subclass as an override. The switch in monthlyFee() dissolves: each subclass simply knows its own fee.
  4. Keep one single switch — in a factory method that converts the raw letter from the register into the right subclass. Creation is the only place the code is still consulted, and it is consulted exactly once.

After this, the language itself does the branching. When you call student.monthlyFee(), the runtime looks at the real class of the object and runs the right method. This built-in dispatch is polymorphism, and this refactoring is the classic scaffolding for Replace Conditional with Polymorphism. In fact, Martin Fowler's second edition of Refactoring treats them as a pair: build the subclasses first, then dissolve the conditionals into them.

💡

One-line summary: Replace Type Code with Subclasses turns a behaviour-driving type code into one subclass per type, so scattered switches collapse into polymorphic method calls — with the type fixed at construction, forever.

The "forever" in that sentence is not decoration. An object's class is chosen the moment it is constructed, and no mainstream language lets you reassign it later. That is why this refactoring demands a type that is immutable for the object's lifetime. Our school made that true by design: changing type means a fresh admission record, a fresh object. If your domain needs the same object to flip types — a SIM moving from prepaid to postpaid — subclasses are the wrong tool, and the third sibling, Replace Type Code with State/Strategy, is waiting for you.

College corner: why exactly can an object not change its class at runtime? Think about how the runtime lays out an object: a block of memory whose shape (fields, size) and method dispatch table (vtable) are fixed by its class at allocation time. Reassigning the class would mean reshaping live memory and rewiring every reference that points at it — which is why C#, Java, TypeScript, and C++ all forbid it. (Python technically allows assigning to __class__, and Smalltalk has become:, but both are considered last-resort surgery, not design.) The honest, portable way to "change type" is composition — hold the changing part as a replaceable field — which is precisely what State/Strategy does.

🚨 When do we need it?

Look for this combination of signs:

  • A type code exists — a letter, number, or string standing for "what kind of thing this is". That alone is Primitive Obsession.
  • Methods branch on it. switch (this.type) or if (type === 'H') appears inside monthlyFee(), canLeaveAt3pm(), dinnerCount(). Behaviour genuinely varies per code. This is the Switch Statements smell — and remember, Fowler renamed it "Repeated Switches" because the repetition is the real crime.
  • The same ladder repeats in several methods. Three methods, three copies of the D/B/H branching. Adding type W means finding and editing all three — and the compiler will not warn you about the one you miss. Ask Bahadur how that feels.
  • Some fields only make sense for some types. hostelRoomNumber sits on every student but is meaningful only for hostellers; for day-scholars it is null and everyone tiptoes around it.
  • The type never changes on a live object. Confirm this with the domain experts, not just the current code. "Does a student record ever flip from D to H?" If the answer is "no, we re-admit," subclasses fit. If "yes, same record changes" — stop, use State/Strategy.

When only the first sign is present (a code but no behaviour), do not bring the heavy machinery — a value class or enum is enough. The decision guide below makes this choice mechanical.

🔄 Before and after at a glance

Here is the school's billing code before the refactoring. Count the switches, and imagine each one as another staff notebook.

// BEFORE: one class, one type code, switches everywhere
type StudentType = "D" | "B" | "H";
 
class Student {
  constructor(
    public name: string,
    public type: StudentType,
    public tuitionFee: number,
    public hostelRoomNumber: string | null, // only means something for H
  ) {}
 
  monthlyFee(): number {
    switch (this.type) {
      case "D": return this.tuitionFee;
      case "B": return this.tuitionFee + 5 * 800;      // 5-day mess
      case "H": return this.tuitionFee + 7 * 800 + 3000; // mess + hostel
    }
  }
 
  canLeaveAt3pm(): boolean {
    switch (this.type) {           // the SAME ladder again
      case "D": return true;
      case "B": return false;
      case "H": return false;
    }
  }
}

And after. One abstract base, three subclasses, one factory.

// AFTER: each kind carries its own rules
abstract class Student {
  constructor(public name: string, protected tuitionFee: number) {}
 
  abstract monthlyFee(): number;
  abstract canLeaveAt3pm(): boolean;
 
  static fromRegister(
    type: "D" | "B" | "H",
    name: string,
    tuitionFee: number,
    hostelRoomNumber?: string,
  ): Student {
    switch (type) { // the ONE remaining switch: creation only
      case "D": return new DayScholar(name, tuitionFee);
      case "B": return new Boarder(name, tuitionFee);
      case "H": return new Hosteller(name, tuitionFee, hostelRoomNumber!);
    }
  }
}
 
class DayScholar extends Student {
  monthlyFee(): number { return this.tuitionFee; }
  canLeaveAt3pm(): boolean { return true; }
}
 
class Boarder extends Student {
  monthlyFee(): number { return this.tuitionFee + 5 * 800; }
  canLeaveAt3pm(): boolean { return false; }
}
 
class Hosteller extends Student {
  constructor(name: string, tuitionFee: number,
              public hostelRoomNumber: string) { // lives ONLY where it belongs
    super(name, tuitionFee);
  }
  monthlyFee(): number { return this.tuitionFee + 7 * 800 + 3000; }
  canLeaveAt3pm(): boolean { return false; }
}

Three wins to notice. First, the behavioural switches are gonestudent.monthlyFee() just works, because each object knows its own formula, the way each student knows their own timetable. Second, hostelRoomNumber moved onto Hosteller alone; day-scholars no longer carry a meaningless null. Third, abstract methods turn forgetfulness into compile errors: when the school adds class WeeklyBoarder extends Student, the compiler refuses to build until monthlyFee() and canLeaveAt3pm() are written. The gatekeeper can never be forgotten again — Bahadur's rule is demanded by the compiler itself.

Figure 3: Before, every method re-checks the letter; after, one factory creates the right subclass and polymorphism does the branching

The shape of the result is the textbook polymorphic hierarchy — small, flat, and honest.

Figure 4: The hierarchy after the refactoring — each kind answers the same questions in its own way

And here is what actually happens at runtime when the billing desk prints a bill. Notice that the letter H is consulted exactly once — at creation — and after that, nobody asks "what type are you?" ever again. They just ask "what is your fee?" and the right answer comes back.

Figure 5: One creation switch, then pure polymorphic calls — nobody re-checks the letter

🧭 Which of the three should I pick?

There are three type-code refactorings, and the choice between them confuses everyone at first. Here is the trick that makes it easy: ask just two questions about your type code.

Question 1: Does behaviour vary by the code? Do methods do different work per type — are there switch/if ladders keyed on it?

Question 2: Can the type change at runtime? Can the same object move from one type to another during its life?

Behaviour varies by code?Type can change at runtime?Pick this refactoringEveryday example
No — pure labelDoesn't matterReplace Type Code with Class (or a plain enum)School house badge — Red, Blue, Green, Yellow; all behave the same
YesNo — fixed for the object's whole lifeReplace Type Code with SubclassesDay-scholar vs boarder vs hosteller — different fees and timings, but a record never flips type
YesYes — the same object switches typeReplace Type Code with State/StrategyA SIM that moves from prepaid to postpaid — same number, new behaviour
Figure 6: The decision flowchart — two questions pick the right refactoring every time

Our D/B/H students sit squarely in the middle row: fees and gate rules vary (yes to question 1), and a record never changes type (no to question 2). Subclasses are the simplest tool that fits — no extra delegation object, no indirection, just plain inheritance.

On the two-axis map, the D/B/H code lands in the bottom-right corner: behaviour differs a lot, but the type is carved in stone at admission.

Figure 7: Student types live in the Subclasses corner — strong behaviour split, zero runtime movement

This same table and map appear in all three posts of this series, on purpose. Whichever post you land on first, you carry away the full map.

One more visual that is worth a hundred words. People sometimes worry: "if a student shifts into the hostel, doesn't the type change?" Look at how the school actually models it — the record is closed and a new record opens. No object ever crosses from one type to another; there is simply no arrow between the types.

Figure 8: A record's life — each record is born one type and dies the same type; changing type means a fresh record

If your domain refuses this model — if the same live object truly must flip — that missing arrow is exactly what State/Strategy draws for you.

🪜 Step-by-step, the safe way

Big-bang rewrites break things. Here is the gentle, always-compiling route, adapted from Fowler's mechanics.

Step 1: Self-encapsulate the type code. Make all reads go through a getter. This gives you a single seam to play with.

class Student {
  private _type: "D" | "B" | "H";
  get type() { return this._type; } // every reader now uses this
  // ...
}

Step 2: Introduce the factory. Add a static creation method that, for now, just calls the constructor. Route every new Student(...) in the codebase through it. Behaviour is unchanged; you have only centralized creation.

Step 3: Create empty subclasses, one per code. At first each subclass only identifies itself by overriding the getter:

class DayScholar extends Student {
  get type(): "D" { return "D"; } // intermediate stage: subclass exists,
}                                  // but all logic still sits in the base

Update the factory to return the right subclass per code. Compile, run tests. The program behaves exactly as before — the switches in the base class still run — but the hierarchy now exists.

Step 4: Push down one method at a time. Pick one switching method, say monthlyFee(). Declare it abstract on the base, and move each branch into the matching subclass. Compile and test. Then take the next method. One method per step, green tests between steps.

Step 5: Delete the dead code. When no behavioural switch remains, remove the _type field and its constants from the base. The letter now lives only in the factory's parameter and at I/O boundaries (the register file, the database column).

⚠️

Two traps to avoid. First, do not push down all methods at once — if a test fails, you will not know which move broke it. One method per step keeps every failure small and obvious. Second, do not try to delete the factory's switch. One switch, in one place, converting flat data into objects, is the normal and healthy price of construction — the smell was never "a switch exists", it was "the same switch repeated everywhere".

🏗️ A bigger real-life example

Let us grow the example to feel like real software. The school's app must also compute the dinner headcount for Mrs. Negi and print fee receipts for Mr. Joshi. Before the refactoring, those features each grew their own copy of the D/B/H ladder, and the W-student incident happened exactly as the story told. After the refactoring, here is the full picture:

abstract class Student {
  constructor(public name: string, protected tuitionFee: number) {}
 
  abstract monthlyFee(): number;
  abstract canLeaveAt3pm(): boolean;
  abstract dinnersPerWeek(): number;
  abstract typeLabel(): string;
 
  receipt(): string {
    // SHARED behaviour stays on the base — no duplication
    return `${this.name} (${this.typeLabel()}): Rs. ${this.monthlyFee()}`;
  }
}
 
class DayScholar extends Student {
  monthlyFee() { return this.tuitionFee; }
  canLeaveAt3pm() { return true; }
  dinnersPerWeek() { return 0; }
  typeLabel() { return "Day-scholar"; }
}
 
class Boarder extends Student {
  monthlyFee() { return this.tuitionFee + 5 * 800; }
  canLeaveAt3pm() { return false; }
  dinnersPerWeek() { return 5; }
  typeLabel() { return "Boarder"; }
}
 
class Hosteller extends Student {
  constructor(name: string, tuitionFee: number,
              public hostelRoomNumber: string) {
    super(name, tuitionFee);
  }
  monthlyFee() { return this.tuitionFee + 7 * 800 + 3000; }
  canLeaveAt3pm() { return false; }
  dinnersPerWeek() { return 7; }
  typeLabel() { return "Hosteller"; }
}
 
// NEW REQUIREMENT: weekly boarders with Saturday classes.
// We ADD a class. We edit NOTHING that already works.
class WeeklyBoarder extends Student {
  monthlyFee() { return this.tuitionFee + 6 * 800; }
  canLeaveAt3pm() { return false; }
  dinnersPerWeek() { return 6; }
  typeLabel() { return "Weekly Boarder"; }
}
 
// Features no longer branch at all:
function dinnerHeadcount(students: Student[]): number {
  return students.reduce((sum, s) => sum + s.dinnersPerWeek(), 0);
}
 
function gateList(students: Student[]): Student[] {
  return students.filter((s) => s.canLeaveAt3pm());
}

Study WeeklyBoarder for a moment, because it is the whole reward. The new type arrived, and we touched one new file. dinnerHeadcount, gateList, and receipt did not change by a single character — yet all of them handle weekly boarders correctly, automatically. Kabir walks freely past Bahadur's gate, because Bahadur's gate code never needed to learn about W at all. The compiler forced us to answer every question a WeeklyBoarder must answer (abstract made forgetting impossible). This is the famous open/closed principle in action: open for extension, closed for modification. Compare that with the before-world, where type W meant hunting three switch ladders and praying you found them all.

Figure 9: Adding a new type — before, edit every switch; after, add one subclass and existing code stays untouched

The school's developer kept a simple tally of how many places needed editing whenever a new student type arrived, before and after the refactoring. The chart speaks for itself — and remember, every "place to edit" is also a place to forget.

Figure 10: Edits needed to add one new student type — and each edit in the old world was a chance to repeat the Bahadur incident

College corner: the "one new subclass, zero edits elsewhere" property has a precise name in programming-language theory: this design optimizes for adding new types. The opposite design — a closed set of types with switches — optimizes for adding new operations. This trade-off is called the expression problem. Object-oriented hierarchies make new types cheap and new operations expensive (a new abstract method touches every subclass); functional-style sum types with pattern matching make new operations cheap and new types expensive. Neither is universally better; ask which direction your code actually grows in. School student types grow by type (W arrived, more will come), so subclasses are the right side of the trade.

💻 The same refactoring in C# and Python

C# gives this refactoring first-class support, and it also offers a couple of interesting alternatives on the lighter end.

The straightforward hierarchy — abstract base, sealed subclasses, one factory:

public abstract class Student
{
    public string Name { get; }
    protected decimal TuitionFee { get; }
 
    protected Student(string name, decimal tuitionFee)
        => (Name, TuitionFee) = (name, tuitionFee);
 
    public abstract decimal MonthlyFee();
    public abstract bool CanLeaveAt3Pm();
 
    public static Student FromRegister(char type, string name,
        decimal tuitionFee, string? roomNumber = null) =>
        type switch // the one creation switch
        {
            'D' => new DayScholar(name, tuitionFee),
            'B' => new Boarder(name, tuitionFee),
            'H' => new Hosteller(name, tuitionFee, roomNumber!),
            _   => throw new ArgumentException($"Unknown student type: {type}")
        };
}
 
public sealed class DayScholar(string name, decimal fee) : Student(name, fee)
{
    public override decimal MonthlyFee() => TuitionFee;
    public override bool CanLeaveAt3Pm() => true;
}
 
public sealed class Boarder(string name, decimal fee) : Student(name, fee)
{
    public override decimal MonthlyFee() => TuitionFee + 5 * 800m;
    public override bool CanLeaveAt3Pm() => false;
}
 
public sealed class Hosteller(string name, decimal fee, string roomNumber)
    : Student(name, fee)
{
    public string HostelRoomNumber { get; } = roomNumber;
    public override decimal MonthlyFee() => TuitionFee + 7 * 800m + 3000m;
    public override bool CanLeaveAt3Pm() => false;
}

abstract members give the same compiler guarantee as in TypeScript: a new subclass that forgets MonthlyFee() simply does not compile.

What about plain enums? A C# enum StudentType { DayScholar, Boarder, Hosteller } plus switch expressions is tempting, and modern C# even warns when a switch expression misses an enum member. But the behaviour still lives outside the type, scattered in whichever classes hold the switches — and every new member means revisiting all of them. Enums shine for the no-behaviour case from the Class refactoring; they do not remove repeated behavioural switches.

The middle path: smart enums with behaviour. The Ardalis SmartEnum library supports an elegant trick — the enum class itself is abstract, and each named instance is a tiny subclass supplying the behaviour:

using Ardalis.SmartEnum;
 
public abstract class StudentType : SmartEnum<StudentType>
{
    public static readonly StudentType DayScholar = new DayScholarType();
    public static readonly StudentType Boarder = new BoarderType();
    public static readonly StudentType Hosteller = new HostellerType();
 
    private StudentType(string name, int value) : base(name, value) { }
 
    public abstract decimal MonthlyFee(decimal tuition);
 
    private sealed class DayScholarType() : StudentType("DayScholar", 1)
    { public override decimal MonthlyFee(decimal t) => t; }
 
    private sealed class BoarderType() : StudentType("Boarder", 2)
    { public override decimal MonthlyFee(decimal t) => t + 5 * 800m; }
 
    private sealed class HostellerType() : StudentType("Hosteller", 3)
    { public override decimal MonthlyFee(decimal t) => t + 7 * 800m + 3000m; }
}

This keeps everything in one file and gives free parsing (StudentType.FromValue(2)), which suits smaller variations. For richer types — where subclasses carry their own fields like HostelRoomNumber and many behaviours — the full hierarchy above remains the cleaner home. Both are honest implementations of today's refactoring: behaviour lives on the type, and switches melt away.

And in Python, the abstract base class machinery plays the compiler's role at instantiation time — you cannot even create a subclass instance that forgot a rule:

from abc import ABC, abstractmethod
 
class Student(ABC):
    def __init__(self, name: str, tuition_fee: int):
        self.name = name
        self.tuition_fee = tuition_fee
 
    @abstractmethod
    def monthly_fee(self) -> int: ...
 
    @abstractmethod
    def can_leave_at_3pm(self) -> bool: ...
 
class DayScholar(Student):
    def monthly_fee(self) -> int:
        return self.tuition_fee
 
    def can_leave_at_3pm(self) -> bool:
        return True
 
class Hosteller(Student):
    def __init__(self, name: str, tuition_fee: int, room: str):
        super().__init__(name, tuition_fee)
        self.hostel_room_number = room  # lives only where it belongs
 
    def monthly_fee(self) -> int:
        return self.tuition_fee + 7 * 800 + 3000
 
    def can_leave_at_3pm(self) -> bool:
        return False
 
# WeeklyBoarder missing monthly_fee()? TypeError the moment you instantiate it.

College corner: modern languages added a beautiful refinement here — sealed hierarchies. Java's sealed interface Student permits DayScholar, Boarder, Hosteller, Kotlin's sealed class, and C#'s closed pattern-matching over a known hierarchy all let the compiler know the complete list of subclasses. The payoff is exhaustiveness from the other direction: a switch over a sealed type that misses a case fails to compile, just like an enum switch. TypeScript achieves the same with discriminated unions and a never check. Sealed hierarchies give you both halves of safety at once: polymorphism for behaviour that belongs on the type, plus exhaustive matching for the rare boundary code (serializers, mappers) that legitimately must enumerate the types.

📊 Benefits and risks

BenefitsRisks / costs
Behavioural switches disappear — polymorphism branches for youType must never change at runtime — if it does, this is the wrong tool; use State/Strategy
Each type's rules live together in one cohesive classOne class per type — heavy machinery for a behaviour-free label (use a class/enum instead)
New types are added, not patched in — existing code stays untouched (open/closed)Only one type code can drive the hierarchy; a second varying code forces combinatorial subclasses
abstract methods make the compiler demand every behaviour from every typeA creation switch remains in the factory (normal, but it exists)
Type-specific fields (hostel room) live only on the type that needs themInheritance couples subclasses to the base; deep careless hierarchies can grow rigid
Each subclass is small and independently testablePersistence needs mapping: a flat type column must round-trip through the factory

🧪 Which smells does it cure?

SmellHow this refactoring helps
Switch StatementsThe primary cure — repeated behavioural switches collapse into polymorphic dispatch
Primitive ObsessionThe letter code that drove behaviour becomes real types
Shotgun surgeryAdding a type touches one new subclass instead of every switch in the codebase
Data class with null-padded fieldsType-specific fields move onto the only subclass where they mean something
Duplicated conditional logicThe single source of truth for each type's rules is that type's own class

🗺️ The whole idea in one picture

Figure 11: Replace Type Code with Subclasses — the signs, the demand, the wins, and the wrong fit

📦 Quick revision box

+----------------------------------------------------------------+
|      REPLACE TYPE CODE WITH SUBCLASSES - REVISION CARD         |
+----------------------------------------------------------------+
| Problem  : type code DRIVES behaviour -> same switch ladder    |
|            copy-pasted across methods, drifting out of sync    |
| Demand   : type is FIXED for the object's whole life           |
| Solution : abstract base + one subclass per code,              |
|            switch branches become overrides,                   |
|            ONE switch survives - in the factory (creation)     |
| Result   : new type = new subclass; old code untouched;        |
|            compiler enforces every abstract behaviour          |
|                                                                |
| WHICH OF THE THREE?                                            |
|   no behaviour varies            -> CLASS / ENUM               |
|   behaviour varies, type fixed   -> SUBCLASSES   (this one)    |
|   behaviour varies + type changes-> STATE / STRATEGY           |
+----------------------------------------------------------------+

✍️ Practice exercise

Your turn. A courier company codes its shipments: "S" = Standard, "E" = Express, "O" = Overnight. The code below already shows the smell — the same ladder twice, and a third feature (insurance) is about to copy it again.

class Shipment {
  constructor(
    public weightKg: number,
    public kind: "S" | "E" | "O",
  ) {}
 
  price(): number {
    if (this.kind === "S") return this.weightKg * 40;
    if (this.kind === "E") return this.weightKg * 70 + 50;
    return this.weightKg * 120 + 150; // overnight
  }
 
  deliveryDays(): number {
    if (this.kind === "S") return 5;
    if (this.kind === "E") return 2;
    return 1;
  }
}

Refactor it step by step:

  1. Confirm the two questions first: behaviour varies (yes — price and days), and a shipment never changes kind after booking (assume yes). So Subclasses is the right pick.
  2. Make Shipment abstract with abstract price() and abstract deliveryDays(), and write a static factory Shipment.fromCode(kind, weightKg) holding the one creation switch.
  3. Create StandardShipment, ExpressShipment, and OvernightShipment, moving one method's branches at a time — compile between moves, exactly like the five safe steps above.
  4. Now add the new requirement: "SD" = Same-Day delivery (price weightKg * 200 + 300, 0 days). Prove to yourself that you only added a class and one factory line, and that the compiler refused to build until both methods existed.
  5. Bonus thinking: the marketing team proposes "a customer can upgrade a booked Standard shipment to Express by paying the difference — same shipment, new kind." Which row of the decision table does the requirement just move you to? Write one sentence explaining what you would do.

If question 5 made you say "the same object now changes type, so I need State/Strategy," you are completely ready for the third post in this family: Replace Type Code with State/Strategy, where one stubborn SIM card refuses to change its number but happily changes its rules.

Frequently asked questions

How is this different from Replace Type Code with Class?
Replace Type Code with Class only names and validates the values; it is for codes that are pure labels. Replace Type Code with Subclasses is for codes that drive behaviour — methods do different work per type — and it moves each behaviour into its own subclass so switches disappear.
Why must the type be fixed for the object's lifetime?
Because an object's class is decided at construction and can never be reassigned. If the same object must change type at runtime — like an account upgrading from basic to premium — subclasses cannot model it, and you need State/Strategy, which swaps a collaborator object instead.
Is it bad that a switch still remains in the factory method?
No, that one switch is normal and healthy. Converting flat data like a D, B, or H code into the right object has to happen somewhere. The win is that it happens in exactly one place, instead of being copy-pasted into every method that needs type-specific behaviour.
What if two different type codes vary on the same object?
Subclassing on both would explode into combinations like DayScholarScienceStream, BoarderScienceStream, and so on. An object can only have one superclass chain. For a second varying dimension, compose: keep subclasses for one code and use State/Strategy objects for the other.
How does this refactoring relate to Replace Conditional with Polymorphism?
Replace Type Code with Subclasses builds the class hierarchy — the scaffolding. Replace Conditional with Polymorphism then moves each switch branch into the matching subclass. In practice you perform them together: first create the subclasses, then dissolve the conditionals into overrides.

Further reading

Related Lessons