A conditional is often the clearest way to express a small decision. Problems begin when the same type question spreads through a codebase. One function asks whether a notification is an email or SMS to format it. Another asks the same question to validate it. A third asks again to calculate delivery cost. Adding a new notification type then means finding and changing several unrelated switches.

This is a useful signal for polymorphism: different implementations respond to the same operation according to their own behavior. The goal is not to remove every if or switch. The goal is to stop many callers from repeatedly deciding what an object is before they can decide what it does.

This article develops a practical mental model for recognizing that situation, shows a safe refactoring path, and explains when a plain conditional remains the simpler design.

The problem is repeated knowledge about types

Consider a delivery system with three message types:

format(message):
    switch message.kind:
        case EMAIL:
            return emailTemplate(message.subject, message.body)
        case SMS:
            return truncate(message.body, 160)
        case PUSH:
            return pushPayload(message.title, message.body)

By itself, this switch is not necessarily a problem. It is local, readable, and makes the alternatives obvious.

Now suppose similar switches appear elsewhere:

validate(message):
    switch message.kind:
        case EMAIL: ...
        case SMS: ...
        case PUSH: ...

estimateCost(message):
    switch message.kind:
        case EMAIL: ...
        case SMS: ...
        case PUSH: ...

The important smell is not the number of branches. It is that several parts of the program must know the same list of message kinds and map each kind to its behavior.

That creates change amplification. Adding VOICE is conceptually one feature, but the type knowledge is distributed. A developer must locate every relevant conditional, add a branch, and keep the branches consistent. Missing one can produce a runtime error or, worse, plausible but incorrect behavior.

A useful mental model is:

one place chooses which kind exists
many places should ask that kind to do its job

Polymorphism moves the second responsibility behind a common interface.

Start by moving one behavior

Do not begin by designing a large class hierarchy. Take one repeated decision and move it behind one operation.

The formatting switch can become:

interface Message:
    format()

EmailMessage:
    format():
        return emailTemplate(subject, body)

SmsMessage:
    format():
        return truncate(body, 160)

PushMessage:
    format():
        return pushPayload(title, body)

The caller changes from this:

content = format(message)

to this:

content = message.format()

The visible difference is small. The ownership of the decision is not. The caller no longer needs to know which message kinds exist. It relies on the narrower promise that every Message can format itself.

This is the central benefit: callers depend on a capability instead of a catalogue of concrete types.

Keep construction separate from behavior

Polymorphism does not make type selection disappear. Somewhere, external input still has to become a concrete implementation.

For example:

createMessage(input):
    switch input.kind:
        case EMAIL:
            return EmailMessage(input.subject, input.body)
        case SMS:
            return SmsMessage(input.body)
        case PUSH:
            return PushMessage(input.title, input.body)

This switch can be perfectly reasonable. It is a boundary where raw data is translated into a program concept. Once construction finishes, most application code can work with Message rather than repeatedly inspecting kind.

The distinction matters. Replacing conditionals with polymorphism does not mean “no switches anywhere.” It means centralize selection, distribute behavior. One place decides which implementation to create; each implementation owns the behavior that varies with that choice.

If every caller still checks message.kind after polymorphic objects are created, the refactoring has not removed the duplicated type knowledge.

Move behavior only when it belongs to the variation

Suppose validation also varies by message type. Moving it can make sense:

interface Message:
    format()
    validate()

An SMS can enforce its own length rule, while an email can require a subject. The rules vary for the same reason the concrete message types vary.

But not every operation involving a message belongs on Message.

Imagine a billing rule:

cost = pricingPlan.price(message, customerRegion, contract)

If pricing depends mainly on customer contracts and regional policy, forcing estimateCost() onto each message type may scatter pricing knowledge across classes. The behavior happens to use a message, but the message type may not be the concept that owns the rule.

Ask a stronger question than “can this method go on the object?”:

Does this behavior change primarily when this type changes?

If yes, the type is a plausible owner. If the behavior changes for a different reason, keep it with the concept that owns that reason.

Why the refactoring reduces change amplification

Assume the system has formatting, validation, and routing conditionals for every message kind. Adding VOICE requires changes in all three switches:

format -> add VOICE
validate -> add VOICE
route -> add VOICE

After those behaviors live behind the Message interface, the new implementation can collect them:

VoiceMessage:
    format(): ...
    validate(): ...
    route(): ...

Existing callers continue to invoke the interface:

message.validate()
content = message.format()
message.route()

The change surface becomes more local because knowledge about voice-specific behavior is concentrated in the voice implementation.

This does not guarantee that adding a type changes only one file. Construction, registration, tests, configuration, or user-facing documentation may also need updates. The practical improvement is narrower: code that merely uses the common capability does not need a new branch just because another implementation exists.

The trade-off: adding types versus adding operations

Polymorphism changes which kind of extension is convenient.

With type-based switches, adding a new operation can be straightforward: write one new function with a switch covering the existing types. Adding a new type is more expensive because many existing switches may need another branch.

With behavior on polymorphic types, the trade-off reverses. Adding a new type can be local because it implements the existing interface. Adding a new required operation can affect every implementation because each one must define that operation.

Consider two expected directions of change:

System A:
message kinds change often
operations are fairly stable

System B:
message kinds are stable
new analyses are added often

Polymorphism is often a better fit for System A. A centralized representation with operations over it may be simpler for System B.

This is why “replace switch with polymorphism” is not a universal rule. The design should make the likely changes easy without making unlikely changes unnecessarily complex.

Refactor without changing behavior

A safe migration is easier when each step has one purpose.

First, make sure the existing conditional behavior is covered by tests at an appropriate level. The tests should describe observable results, not the internal presence of a switch.

Second, introduce the common operation and implement it for one concrete type. Route that type through the new path while leaving the other branches unchanged.

For example:

format(message):
    if message is EmailMessage:
        return message.format()

    switch message.kind:
        case SMS: ...
        case PUSH: ...

This intermediate state is intentionally temporary. It lets the team verify one move before repeating it.

Third, move the remaining type-specific behavior. Once every concrete type implements format(), replace the old dispatch function with the common call and delete the obsolete branches.

Finally, search for other conditionals over the same type distinction. Move only the behaviors that genuinely belong to that variation. The goal is coherent ownership, not mechanical elimination of conditionals.

Common mistakes

Creating subclasses that contain almost nothing

If implementations only store different labels while all meaningful behavior remains in switches elsewhere, the hierarchy adds structure without removing type knowledge. Polymorphism earns its cost when implementations own behavior that actually varies.

Moving unrelated responsibilities into the types

Once a type has methods, it can become tempting to put every operation involving that type onto it. That creates large objects with unrelated reasons to change. Keep behavior near the concept that owns the rule, not merely near data it happens to consume.

Keeping a public type code as a second source of truth

A design can accidentally expose both a concrete type and a mutable kind field:

SmsMessage(kind = EMAIL)

Now two representations can disagree. If the concrete implementation determines behavior, avoid a separately mutable discriminator unless a boundary or serialization format genuinely requires one. When a discriminator is required externally, derive or validate it so contradictory states cannot silently appear.

Building a hierarchy for a tiny stable decision

A two-branch conditional used in one place may be easier to read than several types, an interface, a factory, and extra files. Indirection has a maintenance cost. Use it when it removes meaningful repeated knowledge or supports a real extension boundary.

When a conditional is the better design

Keep the conditional when the alternatives are few, stable, and local. A switch that converts a small protocol code into a display label may be clearer as a switch.

A conditional can also be preferable when operations change much more frequently than the set of variants. Keeping all cases for one operation together lets a reader compare them directly and avoids touching every type whenever a new operation is introduced.

Polymorphism becomes more attractive when several callers repeatedly branch on the same distinction, each branch contains meaningful type-specific behavior, new variants are expected, and callers benefit from depending on a stable capability rather than concrete types.

The decision is therefore about change patterns, not syntax.

A practical decision test

When you encounter a repeated type switch, ask three questions:

  1. Is the same distinction repeated? If only one local conditional exists, leave it alone unless there is another design problem.
  2. Do the branches vary because of the type? If the rule belongs to pricing, authorization, workflow state, or another concept, move it there instead.
  3. Which changes more often: variants or operations? Prefer a structure that localizes the changes you realistically expect.

If the answers point toward polymorphism, move one behavior first and verify that callers become simpler. That evidence is more useful than committing to a hierarchy in advance.

Conclusion

Repeated type conditionals are expensive because they distribute knowledge about the same variation across many places. Polymorphism can concentrate that knowledge: choose a concrete implementation at a boundary, then let callers depend on a common operation.

The useful target is not a codebase without switch statements. It is a codebase where each decision has a clear owner. Keep small, stable decisions explicit. When the same type question spreads and every new variant forces edits across unrelated code, move the behavior behind a stable interface so the change is handled where it belongs.