Replace Conditional with Polymorphism When Branches Represent Types
A conditional isn’t a design problem just because it has several branches. Sometimes if or switch is the clearest way to express a decision. Trouble starts when the same type-based decision appears in several places and every new variant requires editing all of them.
Replace Conditional with Polymorphism is a refactoring that moves variant-specific behavior behind a common operation. Instead of asking an object what kind it is and then deciding what to do, callers ask it to perform the behavior directly.
This article shows how to recognize that shape, make the change in small steps, and avoid replacing an understandable conditional with an unnecessary class hierarchy.
The signal is repeated knowledge, not branch count
Consider a delivery system with three shipment types. A small pricing function might be perfectly reasonable:
shipping_cost(shipment):
if shipment.type == "standard":
return shipment.weight * 1.0
if shipment.type == "express":
return shipment.weight * 1.8
if shipment.type == "pickup":
return 0There is one decision in one place. A developer can read it quickly, and adding abstraction would not obviously improve the design.
Now imagine the type distinction spreads:
shipping_cost(shipment):
switch shipment.type: ...
estimated_days(shipment):
switch shipment.type: ...
requires_address(shipment):
switch shipment.type: ...The problem has changed. standard, express, and pickup are no longer just values used by one calculation. They represent variants with different behavior, and knowledge of those variants is duplicated across the codebase.
Adding same_day now means finding every relevant conditional and adding another branch. Missing one can leave the system internally inconsistent: pricing may support the new type while delivery estimates reject it or address validation treats it incorrectly.
That repeated edit pattern is the useful signal. The refactoring aims to put behavior that varies by type in one place per type.
Change the question callers ask
The central change is small. Code moves from asking for a type and interpreting it:
if shipment.type == "pickup":
...to asking for behavior:
shipment.shipping_cost()A simplified design could look like this:
Shipment
shipping_cost()
estimated_days()
requires_address()
StandardShipment
shipping_cost() -> weight * 1.0
estimated_days() -> 5
requires_address() -> true
ExpressShipment
shipping_cost() -> weight * 1.8
estimated_days() -> 2
requires_address() -> true
PickupShipment
shipping_cost() -> 0
estimated_days() -> 0
requires_address() -> falseShipment represents the operations callers may rely on. Each variant supplies its own implementation. The exact language mechanism might be subclasses, interface implementations, protocol-conforming values, function tables, or another form of dynamic dispatch. The engineering idea does not depend on inheritance.
The caller no longer needs to know the complete set of shipment types just to calculate a cost. That knowledge belongs with the implementations that vary.
Refactor one behavior at a time
Moving several large conditionals at once makes it hard to tell whether behavior changed accidentally. A safer sequence keeps the program working after each step.
Start by identifying one conditional whose branches correspond to the same variants. shipping_cost is a good first candidate because its input and output are simple.
Give the variants a common operation:
shipment.shipping_cost()Then move each existing branch into the corresponding implementation without changing its calculation. The first goal is structural equivalence, not improvement.
Before:
shipping_cost(shipment):
if shipment.type == "standard":
return shipment.weight * 1.0
if shipment.type == "express":
return shipment.weight * 1.8
if shipment.type == "pickup":
return 0After:
StandardShipment.shipping_cost():
return weight * 1.0
ExpressShipment.shipping_cost():
return weight * 1.8
PickupShipment.shipping_cost():
return 0Callers switch to shipment.shipping_cost(). Existing tests should continue to assert the same results for representative inputs and boundary cases.
Only after that behavior is stable should you consider moving estimated_days or requires_address. Incremental movement matters because not every conditional involving shipment.type necessarily belongs in the polymorphic interface.
Keep construction separate from behavior
Polymorphism removes type checks from behavior, but something still has to choose which concrete variant to create.
For example, input may contain a type code:
{
"type": "express",
"weight": 4
}A boundary can translate that representation once:
make_shipment(input):
switch input.type:
"standard" -> StandardShipment(input.weight)
"express" -> ExpressShipment(input.weight)
"pickup" -> PickupShipment()This conditional is not evidence that the refactoring failed. The external representation contains a type discriminator, so some code must interpret it. Concentrating that translation at a construction boundary is different from repeating the same type decision throughout business logic.
The useful distinction is between selecting a variant and implementing the variant’s behavior. Selection often needs one explicit mapping. Once selected, normal callers should not repeatedly decode the same type information.
Move only behavior that actually varies by type
A common mistake is to put every operation on the polymorphic interface once the hierarchy exists.
Suppose every shipment displays its tracking identifier in exactly the same way. There is little value in defining three identical tracking_label() implementations. Shared behavior can remain shared.
The opposite mistake is more subtle: moving a conditional that represents a different dimension of change.
Imagine pricing also depends on customer membership:
if customer.is_member:
apply_discount()Membership is not a shipment variant. Forcing that rule into StandardShipment, ExpressShipment, and PickupShipment would mix two independent concepts and could duplicate the membership rule across all three types.
Before moving a branch, ask what makes its behavior vary. If the answer is the shipment variant itself, polymorphism may fit. If the answer is customer status, region, time, feature configuration, or another independent concern, model that concern separately or keep a direct conditional when it remains simple.
Polymorphism changes the cost of future changes
This refactoring does not remove complexity. It reorganizes where the complexity lives.
With centralized conditionals, adding a new operation can be easy: write one function containing branches for all existing variants. Adding a new variant can be expensive because many existing functions may need another branch.
With polymorphic variants, that trade-off tends to reverse. Adding a new variant can be localized because its related behavior can live in one new implementation. Adding a new operation to the common contract may require changing every existing variant.
That difference should influence the design choice. If the set of operations changes frequently while the set of variants is small and stable, a few centralized functions may be easier to maintain. If new variants are common and the operations are relatively stable, polymorphism can reduce scattered edits.
There is no universal winner. The right structure follows the dimension along which the software is expected to change.
Watch for partial and artificial hierarchies
A refactoring can make code look more object-oriented while making its behavior harder to follow. Several failure modes are worth checking.
The caller still switches on the type. If code calls shipment.type immediately after receiving a polymorphic shipment, the type knowledge has not actually been contained. Either behavior is still missing from the abstraction or that caller has a legitimate reason to work with the explicit variant.
Subclasses exist only to avoid a three-line conditional. A stable, local decision with two or three obvious branches may be clearer as a conditional. Extra files and dispatch indirection carry a reading cost.
The hierarchy groups unrelated variation. If subclasses accumulate rules for shipment type, customer tier, geography, and payment method, the model is absorbing independent dimensions. Composition or separate policies may fit those rules better.
A default implementation hides unsupported cases. Returning a neutral value from a base implementation can make a newly added variant appear supported when it is not. When every variant must make an explicit decision, requiring an implementation can expose omissions earlier.
The refactoring changes behavior while moving it. Combining structural change with pricing-rule changes makes failures harder to diagnose. Preserve behavior first; change the rule in a separate step.
When a conditional is still the clearer design
Keep the conditional when the decision is small, local, and unlikely to spread. A parser translating a finite external code into an internal value is a common example. So is a one-off formatting choice with two straightforward cases.
Polymorphism earns its cost when type-specific behavior has become a recurring design concern: the same variants appear across multiple operations, new variants cause scattered edits, or callers know details that should belong to the variants themselves.
You also don’t need to wait until duplication becomes severe. If a domain already has genuine behavioral variants and a stable common contract, modeling them explicitly can be reasonable from the start. The key is that the abstraction represents real variation, not imagined future flexibility.
Use the next change as a design test
When you encounter a type-based conditional, don’t count its branches first. Ask what the next realistic change would require.
If adding one variant means editing a cluster of unrelated functions that all repeat the same type knowledge, the code is telling you where a boundary may be missing. Move one behavior behind that boundary, preserve its existing results, and see whether the next operation becomes simpler to place.
If the conditional remains the only place that needs to know the variants, leave it alone. A good refactoring reduces the number of places that must understand a change. It doesn’t replace direct code merely for the sake of using polymorphism.