Replacing Type Branches with Polymorphism

A type-based switch can be perfectly clear. Trouble starts when the same cases appear across several operations. Adding one new type then means editing pricing, validation, formatting, scheduling, and other branches in separate places. The type list has become a change axis, but the code still represents it as scattered conditionals.

Replacing type branches with polymorphism moves behavior for each variant behind a shared contract. Callers ask for an operation without selecting the implementation themselves. This can concentrate related rules and reduce repeated branching, but it also introduces more types and indirection. The refactoring pays off only when that trade is useful.

Start with the change pattern, not the switch statement

Consider a delivery system with three service levels. A simplified price calculation might look like this:

shipping_cost(service, weight):
    if service == "standard":
        return 5 + weight * 0.5
    if service == "express":
        return 12 + weight * 0.8
    if service == "overnight":
        return 25 + weight * 1.1

There is nothing inherently wrong with this function. It has one obvious responsibility, all cases are visible together, and a reader can inspect the complete rule in one place.

Now suppose dispatch timing has the same branch structure:

dispatch_deadline(service):
    if service == "standard": return "17:00"
    if service == "express": return "15:00"
    if service == "overnight": return "12:00"

Then package validation gains another branch, followed by label formatting. A new service level now requires coordinated edits across several functions. Missing one branch may leave the system internally inconsistent.

That repeated change pattern is the stronger signal. The issue is not that conditionals exist. The issue is that knowledge about one variant is spread across many conditional structures.

Polymorphism moves selection to one boundary

Polymorphism means different implementations can respond through the same contract. Instead of every operation asking which service it received, one part of the system selects a service implementation and later code calls it directly.

A small design might use this contract:

ShippingService:
    cost(weight)
    dispatch_deadline()

Each variant owns its rules:

StandardShipping:
    cost(weight): return 5 + weight * 0.5
    dispatch_deadline(): return "17:00"

ExpressShipping:
    cost(weight): return 12 + weight * 0.8
    dispatch_deadline(): return "15:00"

The caller changes from this:

cost = shipping_cost(order.service, order.weight)

to this:

cost = order.shipping_service.cost(order.weight)

The selection has not vanished. Some boundary still has to turn input such as "express" into an ExpressShipping implementation. The improvement is that selection happens in one deliberate place rather than being repeated inside every operation.

For example:

shipping_service(code):
    if code == "standard": return StandardShipping()
    if code == "express": return ExpressShipping()
    if code == "overnight": return OvernightShipping()
    reject unknown code

This factory is still conditional code. That is fine. Its job is selection. The behavioral code no longer needs to repeat that selection.

Refactor one behavior at a time

A broad rewrite makes this change harder to verify than necessary. A safer sequence keeps the existing behavior observable throughout the refactoring.

First, identify one operation whose cases already correspond cleanly to the variants. Create the shared contract and implementations for that operation only. Route existing callers through the new abstraction while keeping tests focused on externally visible results.

Next, move another repeated operation when its ownership is equally clear. Do not move a function merely because it contains the same type names. A rule that genuinely combines several independent concepts may belong in a separate policy object or service.

Once all relevant behavior has moved, remove branches that no longer carry distinct responsibility. At each step, the system should remain usable and testable.

This incremental approach also exposes a bad abstraction early. If the second operation fits awkwardly, that is useful design feedback. It may mean the variants do not share the boundary you first assumed.

Keep variant rules cohesive

The main design benefit comes from putting rules that change together in the same place.

Suppose OvernightShipping has a noon dispatch deadline, a special weight limit, and a distinct surcharge. If those rules usually change as part of the overnight service policy, keeping them near one another gives maintainers a smaller area to inspect.

This does not mean every fact associated with overnight delivery belongs in that class. Database persistence, HTTP parsing, user-interface labels, and analytics dimensions may have separate reasons to change. Polymorphism should model behavioral variation, not become a container for every concern sharing the same type name.

A useful test is to ask what would trigger a change. If two pieces of code change for the same business rule, placing them together may improve cohesion. If they change for unrelated operational or presentation concerns, keeping them separate can preserve clearer boundaries.

Preserve invalid-input handling at the boundary

Moving behavior into implementations can make the happy path tidy while accidentally weakening input handling.

External values are not automatically valid variants. A request containing "teleport" should not silently fall back to standard shipping unless fallback is an explicit product rule. The conversion boundary should reject unknown values or return a result that forces the caller to handle them.

This distinction matters because the internal polymorphic model usually assumes it already represents a valid service. Validation belongs before that assumption becomes true.

The same applies when variants are loaded from stored data. Old records, partially migrated values, or corrupted state can still contain unsupported codes. Treat deserialization as a boundary rather than assuming persisted data is infallible.

Do not replace every conditional

Some conditionals are more readable than an object hierarchy.

A single switch over three stable cases may be the simplest representation, especially when all behavior is naturally tabular. If the cases are just data mappings, a lookup table can be clearer than several classes:

cutoff = {
    "standard": "17:00",
    "express": "15:00",
    "overnight": "12:00"
}

Polymorphism becomes more attractive when each variant has meaningful behavior, several operations branch on the same variants, and new variants are expected to arrive without changing the existing ones much.

It is less attractive when new operations are added frequently across a fixed set of variants. With polymorphism, adding an operation to the shared contract may require touching every implementation. A centralized conditional representation can make that particular change easier.

This is a key trade-off: organize around the dimension that changes most often. Neither structure wins in every codebase.

Watch for abstractions that only hide branches

A weak refactoring can replace one readable switch with a maze of tiny classes while leaving the same coupling intact.

One warning sign is an interface whose implementations do nothing except return constants. Another is a base class full of optional methods that most variants cannot support. That often means the contract is too broad or the variation is mostly data rather than behavior.

Inheritance can also create unnecessary constraints. Polymorphism does not require a deep class hierarchy. Depending on the language, implementations can satisfy an interface, protocol, trait, function contract, or another form of substitutable behavior. Prefer the smallest mechanism that expresses the variation clearly.

Tests deserve the same restraint. Test each implementation’s distinctive rules, then keep a smaller set of integration tests around variant selection. Duplicating the same broad test suite for every implementation can make routine changes expensive without adding useful confidence.

Use the refactoring when it matches the system’s change axis

Replacing type branches with polymorphism works well when repeated conditional structures are evidence of one recurring variation. The refactoring gives each variant a place for its behavior and moves selection toward a boundary.

Before changing the design, inspect actual change patterns. Count the repeated branches, identify which rules move together, and check whether the variants form a stable conceptual family. If one compact conditional already tells the story clearly, keep it.

When scattered branches repeatedly force coordinated edits, move one behavior behind a shared contract and see whether the next behavior fits naturally. A good abstraction should make the next real change more local and easier to reason about, not merely make the switch keyword disappear.