A type-based switch can be the simplest way to express a small rule. The trouble starts when the same distinction appears in several places. Pricing checks whether an order is standard or express. Delivery estimates check the same thing. Cancellation rules do too. Adding a new order type then means finding every branch that knows the list of types.
The problem is not the switch syntax itself. The problem is distributed knowledge: several callers know which variants exist and which behavior belongs to each one.
Polymorphism can move that knowledge behind a common operation. Instead of asking an object what type it is and deciding what to do, a caller asks the object to perform the relevant behavior. This article explains how to recognize that opportunity, refactor toward it safely, and avoid replacing straightforward conditionals with unnecessary class hierarchies.
Start with the repeated decision
Consider a simplified delivery system:
shipping_cost(order):
switch order.delivery_type:
case "standard": return 5
case "express": return 12
estimated_days(order):
switch order.delivery_type:
case "standard": return 5
case "express": return 2Each function is understandable on its own. The design pressure appears when the same discriminator, delivery_type, controls related behavior in multiple places.
Suppose the business introduces scheduled delivery. The developer must remember to update both functions. If cancellation eligibility, insurance, and tracking also branch on delivery_type, the change surface grows further. Missing one branch can leave the system internally inconsistent even though every edited function looks correct.
A useful mental model is:
When many places repeatedly ask the same kind of object which variant it is, the variant may be the better place to own the answer.
This is the design situation often addressed by polymorphism: different implementations respond to the same operation according to their own rules.
Move one behavior behind a common operation
Do not begin by building an elaborate hierarchy. First identify one operation whose meaning is shared across variants.
For shipping cost, the common question is simply shipping_cost():
interface DeliveryMethod:
shipping_cost()
StandardDelivery.shipping_cost():
return 5
ExpressDelivery.shipping_cost():
return 12The caller becomes:
cost = order.delivery_method.shipping_cost()The caller no longer needs to know whether the method is standard or express. It depends on the capability—calculating shipping cost—rather than on the complete list of variants.
The important change is not fewer lines of code. Ownership of the decision has moved. The code that represents each delivery method now owns its shipping rule.
Extend the design only where behavior actually varies
If delivery estimates vary for the same reason, that operation can move too:
interface DeliveryMethod:
shipping_cost()
estimated_days()
StandardDelivery:
shipping_cost(): return 5
estimated_days(): return 5
ExpressDelivery:
shipping_cost(): return 12
estimated_days(): return 2Now adding ScheduledDelivery requires implementing the common delivery operations in one new variant rather than editing every caller that previously switched on the type.
That is useful when the set of operations is relatively stable and the set of variants changes more often. Polymorphism groups behavior by variant, so a new variant can often be added without modifying existing dispatching code.
The trade-off is important. If variants are stable but new operations are added frequently, grouping behavior by variant can make each new operation require edits across many implementations. A central conditional may then be easier to understand. The right structure depends on which dimension of the design changes most often.
Keep object creation separate from behavioral dispatch
Polymorphism does not make type selection disappear. Something still has to decide which concrete implementation to create.
For example, input may contain a delivery code:
make_delivery_method(code):
switch code:
case "standard": return StandardDelivery()
case "express": return ExpressDelivery()This conditional serves a different purpose from the repeated business-rule switches. It translates external data into an internal object. Once that translation happens, the rest of the application can work through the DeliveryMethod abstraction.
A single construction boundary is usually easier to maintain than repeated type checks throughout business logic. In a production system, that boundary might be a parser, factory, dependency-injection configuration, or another composition mechanism. The specific mechanism matters less than containing the mapping in a clear place.
Refactor without changing behavior
When the existing conditional is already in production, move in small steps.
First, identify the discriminator and list every branch for the behavior you are moving. Confirm what each branch currently does, including error cases and defaults. Existing tests are especially valuable here because the goal of the refactoring is to change structure without changing observable behavior.
Next, introduce the common operation and implement one variant at a time. Route callers through the new operation only after each implementation represents the old rule correctly. Once all callers use the abstraction, remove the obsolete conditional.
Do not keep both dispatch mechanisms indefinitely. If some callers use polymorphism while others continue switching on the same type code, the system still has two places where variant knowledge can diverge.
Decide what belongs on the variants
A repeated type check is evidence, not proof, that behavior should move.
The behavior should usually move when it is genuinely a responsibility of the variant. A delivery method can reasonably own its delivery estimate. By contrast, a reporting screen that chooses a display icon for each delivery type may be presentation logic. Moving UI-specific icon selection into core delivery objects could mix unrelated responsibilities just to eliminate a switch.
Ask two questions:
- Does this behavior vary because of the same underlying concept represented by the variants?
- Would placing the behavior on the variant give it a coherent responsibility rather than merely hide a conditional?
If the answer to the second question is no, another boundary may be more appropriate.
Watch for conditionals that encode state, not type
Not every repeated conditional describes interchangeable variants.
Consider:
if invoice.is_overdue:
...An invoice may become overdue and later become paid. That is changing state of the same entity, not necessarily a stable subtype. Creating OverdueInvoice and PaidInvoice classes solely to remove the condition could make state transitions harder to represent.
Similarly, a rule such as if total > 100 is a threshold decision, not type dispatch. Polymorphism is most natural when there are meaningful variants that share an operation and each variant has its own implementation.
State-pattern designs can use polymorphism for changing states, but that is a separate design choice with its own transition model. Do not infer that every boolean or conditional needs a subtype.
Avoid hierarchies that are harder than the switch
A two-case conditional used once may be perfectly adequate:
label = is_priority ? "Priority" : "Normal"Replacing it with interfaces, factories, and multiple classes increases the number of concepts a reader must understand. If the condition is local, unlikely to grow, and does not duplicate business knowledge, the simpler representation may be better.
Polymorphism also has costs. Behavior becomes distributed across implementations, so comparing all variants may require navigating several files or classes. Construction becomes another design concern. Languages with algebraic data types or pattern matching may provide exhaustive checking that a class-based design does not automatically reproduce.
The goal is therefore not to eliminate conditionals. It is to choose where knowledge about variation should live.
Recognize common failure modes
One failure mode is creating subclasses that contain almost no meaningful behavior. If each class only returns a constant while all important rules remain elsewhere, the abstraction may add ceremony without improving ownership.
Another is forcing unrelated dimensions into one hierarchy. Delivery speed and customer membership, for example, can vary independently. Building classes such as ExpressPremiumDelivery and StandardPremiumDelivery can produce combinations that multiply as new dimensions appear. Composition—separate objects for separate policies—often represents independent variation more directly.
A third failure mode is exposing the concrete type again:
if delivery_method is ExpressDelivery:
...If callers repeatedly need concrete-type checks after the refactoring, the common abstraction may be missing an operation, or the variants may not actually share the responsibility you tried to model.
Use the change pattern as your guide
Repeated type conditionals become expensive when a single conceptual change requires synchronized edits across many dispatch sites. Polymorphism addresses that problem by placing variant-specific behavior behind a common operation and letting each variant implement the rule it owns.
Use it when the variants are meaningful, several behaviors repeatedly branch on the same distinction, and adding or changing a variant should not require unrelated callers to know the complete variant list. Keep a conditional when the decision is small, local, naturally belongs to the caller, or is easier to understand as an explicit closed set of cases.
The practical test is simple: look at the next likely change. If adding one variant would make you search the codebase for the same switch again and again, consider moving that decision behind polymorphic behavior. If the switch is already the clearest single place where the rule belongs, leave it there.