Replacing conditionals with polymorphism
On This Lesson 11 topics
Lesson Overview
You now know that a call through a parent reference runs the child's version of an overridden method. This lesson uses that to clean up a very common kind of code: a long if/else if chain or a switch whose only job is to ask "what kind of thing is this?" and then do something different for each kind.
You will learn to recognize that shape, to move each branch into an overriding method of its own class, and to end with a caller that is a single line. The payoff is the main idea of this whole module: adding a new type means adding a class, not editing the code that uses the types.
Why this matters: a type-checking chain tends to be copied into every place that cares about the kind (month-end processing, statements, reports, validation). When a new kind appears, someone must find and edit every chain, and a missed one fails silently. Polymorphism turns that scavenger hunt into a compiler-checked, single-place change.
This lesson builds on lessons 1 to 3 of this module. Where the code throws an exception, treat throw as a preview: exceptions get their own module later, and the earlier invariants lesson used the same form.
Learning Objectives
By the end of this lesson you will be able to:
- Recognize a conditional that switches on the kind of an object.
- Apply the refactoring recipe: add a method to the parent, move each branch into a subclass override, replace the chain with one call.
- Choose a sensible default for the parent method.
- Show, with a new type, that the calling code no longer needs to change.
- Explain which conditionals should not be replaced, and where a type-based
switchstill belongs.
Written Explanation
The smell: a chain that asks "what kind are you?"
Here is a month-end loop written without polymorphism:
for (Account a : accounts) {
if (a instanceof SavingsAccount s) {
s.deposit(s.getBalance() * s.getRate());
} else if (a instanceof CheckingAccount c) {
c.withdraw(c.getFee());
}
}The same shape appears with strings and switch:
if (parcel.type.equals("EXPRESS")) { ... } else if (parcel.type.equals("FREIGHT")) { ... }Both have the same problems:
- Knowledge is in the wrong place. The loop must know the details of every type (interest rate, fee). Those details belong in the type itself.
- It repeats. Every other place that cares about the kind needs its own chain.
- It breaks silently. If a third type appears and nobody updates the chain, nothing in the compiler complains. The new type simply falls through every branch and nothing happens to it.
- String kinds can be mistyped.
"EXPRES"compiles fine and falls through to the default.
The recipe
- Name the operation the chain is performing ("do the month-end work", "compute the cost").
- Add that method to the parent class, with a sensible default body (often "do nothing" or the standard behavior).
- Move each branch into the matching subclass as an
@Overridemethod. The fields the branch used are now the subclass's own fields, so the code gets simpler:s.getBalance() * s.getRate()becomesgetBalance() * rate. - Replace the chain with a single call:
a.monthEnd();. - Run the same data through both versions and check that the output is identical, before deleting the old chain.
After the refactoring, the loop is:
for (Account a : accounts) {
a.monthEnd();
}Choosing the parent's default
The parent method must do something reasonable for types that have nothing special. Options:
- Do nothing (
void monthEnd() { }): right when "no special action" is a valid answer, as with a plain account. - Provide the standard behavior: right when most subclasses share it and only a few differ.
- Refuse to run by throwing an exception: right when running the default would be a bug. Abstract classes, covered in the next module, are a better tool, because they force every subclass to supply its own version.
Open for new types
After the refactoring, the loop depends only on the parent type. A new BusinessAccount with its own monthEnd() works in the existing loop without editing it. Design books call this being open for extension and closed for modification; it is the point of this module's lab.
What not to replace
Not every if is a problem. Leave a conditional alone when it:
- Tests a value, not a kind, such as
if (balance < 0)orif (amount > limit). - Validates input, as in the invariants lesson.
- Decides which object to create from outside data, for example reading a text file whose first column says
"SAVINGS"or"CHECKING". Somewhere, the program must turn text into the right class. Keep that singleswitchin one small method (often called a factory), and keep it out of the processing code. Example 3 shows this.
The rule of thumb: replace conditionals that ask an existing object what kind it is, and keep the ones that decide which object to build or test plain values.
Examples
Example 1: shipping costs without a type string (simple)
Before, a parcel stores a type string and a static method decides the cost:
static double costBefore(ParcelBefore p) {
if (p.type.equals("STANDARD")) {
return 4.0 + p.kg * 1.0;
} else if (p.type.equals("EXPRESS")) {
return 10.0 + p.kg * 2.0;
} else if (p.type.equals("FREIGHT")) {
return 50.0 + p.kg * 0.5;
}
return 0;
}After, each type owns its rule:
class Parcel {
protected final double kg;
Parcel(double kg) { this.kg = kg; }
double cost() { return 4.0 + kg * 1.0; } // standard
}
class ExpressParcel extends Parcel {
ExpressParcel(double kg) { super(kg); }
@Override double cost() { return 10.0 + kg * 2.0; }
}
class FreightParcel extends Parcel {
FreightParcel(double kg) { super(kg); }
@Override double cost() { return 50.0 + kg * 0.5; }
}
// in main:
Parcel[] ps = { new Parcel(3), new ExpressParcel(3), new FreightParcel(40) };
for (Parcel p : ps) System.out.println(p.cost());Output of the new version:
7.0
16.0
70.0Calling costBefore with the type string "EXPRESS" and 3 kg gives 16.0, matching ExpressParcel(3).cost(). With the typo "EXPRES" it returns 0.0: the parcel silently costs nothing. In the new version, a mistyped class name does not compile.
Example 2: month-end, then a new account type (practical)
The "before" loop is the one at the start of this lesson. The "after" version uses monthEnd() as in lesson 1. Suppose we now add a third class:
class BusinessAccount extends Account {
BusinessAccount(String owner, double balance) { super(owner, balance); }
@Override void monthEnd() { withdraw(25.0); } // flat monthly charge
}We build the same three accounts (Ana's savings, Omar's checking, a business account with 2000.0) and run each loop version on a fresh copy:
before: Ana 1020.0
before: Omar 495.0
before: Shop 2000.0
after: Ana 1020.0
after: Omar 495.0
after: Shop 1975.0The old chain never mentions BusinessAccount, so the business account is untouched: 2000.0, no charge, and no error anywhere. The polymorphic loop did the right thing without any edit, because BusinessAccount supplies its own monthEnd(). To repair the old version you would have had to find and edit the chain, and so on for every other chain in the program.
Example 3: the one place a switch still belongs (realistic)
Records often arrive as text, for example from a file or a request: SAVINGS,Ana,1000. A switch converts the text into the right object once, at the edge. Everything after that is polymorphic.
static Account create(String kind, String owner, double balance) {
return switch (kind) {
case "SAVINGS" -> new SavingsAccount(owner, balance, 0.02);
case "CHECKING" -> new CheckingAccount(owner, balance, 5.0);
case "BUSINESS" -> new BusinessAccount(owner, balance);
default -> throw new IllegalArgumentException("Unknown account kind: " + kind);
};
}Processing three rows with create(...) then a.monthEnd() prints:
Ana 1020.0
Shop 1975.0
Omar 495.0and create("GOLD", "X", 1) fails loudly instead of silently:
Exception in thread "main" java.lang.IllegalArgumentException: Unknown account kind: GOLDThe type-based switch is now in one small method that has one job. Adding a new kind means adding a class and one line here; the processing code is untouched. (The default branch is the safety net the chain in the "before" code did not have.)
Backend Insight
Backend systems receive kinds as data: a payment-method field, a document type in a request, an event name in a message. The boundary code converts that data into objects once. The business logic then works with objects and calls methods, not with if (type.equals(...)).
This is why mature codebases tend to have few instanceof checks in the core logic and a handful of well-labelled places that map text to classes. When a new payment method, notification channel, or tax rule is requested, the team adds a class and registers it in the factory. A reviewer can see the new class in isolation and trust that the tested processing code did not move.
The same idea leads to two later topics: the Strategy pattern (keep varying behavior in a swappable object) and dependency inversion (depend on the parent type, not on concrete classes). Both are this lesson with better names.
Common Mistakes
Mistake 1: Replacing the if with another type check
Writing if (a.getKind() == Kind.SAVINGS) with an enum or a getType() method is the same chain with a different spelling. If every caller must still ask "what are you?", the logic has not moved. Move the behavior, not the question.
Mistake 2: Forgetting @Override on the new methods
A subclass method with a slightly different signature is a new, unrelated method. The parent's default silently runs for that subclass. Lesson 5 shows the exact compiler message you get with @Override.
Mistake 3: Replacing every conditional
Turning if (balance < 0) into polymorphism is over-engineering.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. Which of these should be replaced with polymorphism? (a) if (shape instanceof Circle) ... else if (shape instanceof Rectangle) ... computing area; (b) if (age < 18) ...; (c) a switch on a text command "DEPOSIT" / "WITHDRAW" that creates a request object; (d) if (notification.type.equals("EMAIL")) ... else if (notification.type.equals("SMS")) ... choosing how to send.
Show solution A1
A1. (a) and (d): both ask an existing object what kind it is. (b) is a value test and stays. (c) is conversion of outside text into an object, which stays, in one factory method.
A2. Put the refactoring steps in order: replace the chain with one call; add the method to the parent; compare the outputs of old and new code; move each branch into a subclass override; name the operation.
Show solution A2
A2. Name the operation; add the method to the parent; move each branch into a subclass override; replace the chain with one call; compare the outputs of old and new code.
A3. Why does a parent method often have a "do nothing" body, and when is that wrong?
Show solution A3
A3. Because some types have nothing special to do, and "do nothing" is the correct default for them. It is wrong when running the default would hide a bug, for example when every subclass is supposed to define its own behavior. In that case, refuse (throw), or use an abstract method, which a later lesson covers.
Level B: Application
B1. An Employee has a salary and a role string. A chain gives managers a 10% bonus, engineers 5%, everyone else 0. Refactor into classes with a bonus() method, and compute the total bonus of one manager (5000), one engineer (4000), and one other employee (3000).
Show solution B1
B1.
class Employee {
protected final double salary;
Employee(double salary) { this.salary = salary; }
double bonus() { return 0; }
}
class Manager extends Employee {
Manager(double salary) { super(salary); }
@Override double bonus() { return salary * 0.10; }
}
class Engineer extends Employee {
Engineer(double salary) { super(salary); }
@Override double bonus() { return salary * 0.05; }
}
// total over { new Manager(5000), new Engineer(4000), new Employee(3000) } = 500 + 200 + 0 = 700.0I compiled and ran it: the total is 700.0.
B2. An old chain handles Email and Sms notifications. A colleague adds PushNotification to the project and tests it. Push messages are never sent, and no error appears. Explain why and what refactoring prevents it.
Show solution B2
B2. The old chain only has branches for Email and Sms, so a PushNotification matches no branch and is skipped silently. Moving send() into the classes, with a method on the parent that each subclass overrides, means the new class carries its own behavior. If the author forgets, the parent's default is at least visible and testable.
B3. Write the polymorphic version of notifications with a parent Notification whose default send() returns "nothing sent", and subclasses Email and Sms that return "email sent" and "sms sent". Show the loop.
Show solution B3
B3.
class Notification { String send() { return "nothing sent"; } }
class Email extends Notification { @Override String send() { return "email sent"; } }
class Sms extends Notification { @Override String send() { return "sms sent"; } }
// loop:
Notification[] ns = { new Email(), new Sms(), new Notification() };
for (Notification n : ns) System.out.println(n.send());It prints email sent, sms sent, nothing sent. I compiled and ran it.
Level C: Challenge
C1. Customers get a discount on a purchase total: regular customers 0, members 10% of the total, VIPs 20% of the total but at least 5. Replace a customerType string chain with classes, and give the discounts for totals of 100 and 10 for each type.
Show solution C1
C1.
class Customer { double discount(double total) { return 0; } }
class Member extends Customer { @Override double discount(double total) { return total * 0.10; } }
class Vip extends Customer {
@Override double discount(double total) { return Math.max(5, total * 0.20); }
}Discounts for 100 and 10: regular 0.0 and 0.0; member 10.0 and 1.0; VIP 20.0 and 5.0 (the minimum applies for 10). I compiled and ran it.
C2. A program reads lines like CIRCLE,3 and SQUARE,4, then prints each shape's area and the total area. Where should a switch on the first word live, and what should the rest of the code look like?
Show solution C2
C2. The switch on "CIRCLE" / "SQUARE" belongs in a single factory method that returns a Shape, as in Example 3 (with a default that throws). Everything else works with Shape and calls shape.area() in a loop, adding the results. A new shape means a new class and one new case.
Mini Challenge
Take this chain and refactor it, keeping the old code until the outputs match:
static String describe(Vehicle v) {
if (v instanceof Car) return "Car, 4 wheels";
else if (v instanceof Bike) return "Bike, 2 wheels";
return "Unknown vehicle";
}Create Vehicle, Car, Bike, and one new class of your own (for example Scooter). Give Vehicle an overridable describe(). Print the descriptions of one object of each in a single loop, and show that adding your new class required no change to the loop.
Check your work:
- The loop contains one call and no type checks.
- Every subclass method has
@Override. - The parent default (
"Unknown vehicle") appears for a plainVehicle. - You can say which conditionals you deliberately left alone, if any.
Summary & Next Lesson
Key takeaways
- A conditional that asks an existing object what kind it is repeats everywhere, hides details in the wrong place, and fails silently for new types.
- The recipe: name the operation, add it to the parent with a default, move each branch into an override, replace the chain with one call, and compare outputs.
- After refactoring, adding a type means adding a class; the calling loop is unchanged.
- Keep value tests, validation, and the single factory
switchthat turns outside text into objects. - Do not re-create the chain inside the parent with
instanceofor agetType()check.
Next lesson
The next lesson, "Demo: month-end processing without a single type check", is a worked demonstration of exactly this module goal: start with an instanceof loop, refactor it, deliberately make an override mistake, see how @Override catches it, and add a new account type to the untouched loop. It prepares the module's lab.
Self-check
- What question does a type-checking chain ask that polymorphism removes?
- What are the first two steps of the refactoring recipe?
- Why does a new subclass not require edits to the polymorphic loop?
- Name one kind of conditional you should leave alone.
- Why is replacing
instanceofwithif (a.getType() == SAVINGS)not a real fix?
Show answers
- "What kind are you?" Each object answers by running its own method.
- Name the operation; add that method to the parent with a sensible default.
- The loop calls a method declared on the parent type, and dynamic dispatch picks the new class's override at run time.
- Value tests such as
balance < 0, input validation, or the one factoryswitchthat creates objects from text. - Callers still ask the object what kind it is, so the chain (and every place that must be updated when a type is added) remains.
References
- Oracle, Java Tutorials, Polymorphism. Supports the idea that the JVM runs the actual object's method for an overridden method, which is what makes one call replace a type-checking chain.
- Oracle, Java Language Specification SE 21, 15.12.4.4 Locate Method to Invoke. Supports the dynamic lookup of the method from the target object's run-time class.
- Oracle, Java SE 21 API, Override annotation. Supports the recommendation to use
@Overrideso that a wrong signature is a compile error. - I did not fetch sources for the open-closed principle or the Strategy pattern; they are mentioned only as names for what later modules cover.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.