A switch statement is not a design problem by itself. When a program has a small, stable set of cases, one explicit conditional can be easier to read than a hierarchy of types.
Trouble starts when the same type distinction controls behavior in several places. Adding one new case then means finding every switch that knows about that type. Missing one produces a system where the new case works in some operations but not others.
One way to change that dependency is polymorphism: callers work through one behavioral interface, while each implementation provides the behavior for its own case. This article explains how to recognize a behavior switch, refactor it progressively, and decide when the extra abstraction is justified.
Recognize a type code that keeps making decisions
Consider a notification system with three channels:
function send(notification):
switch notification.channel:
case "email":
sendEmail(notification)
case "sms":
sendSms(notification)
case "push":
sendPush(notification)For one operation, this may be perfectly reasonable. The code states the alternatives in one place.
Now suppose the same distinction appears again:
function deliveryLabel(notification):
switch notification.channel:
case "email": return "Email"
case "sms": return "Text message"
case "push": return "Push notification"And again when validating channel-specific fields.
The important signal is not the number of lines in any one switch. It is the repeated question:
what kind of notification is this?When many operations ask that question before choosing behavior, knowledge about each kind is spread across the codebase. A new webhook channel may require edits to sending, validation, labeling, retry policy, and other switches.
That creates change coupling: one conceptual change requires coordinated edits in several places.
Start with the behavior, not the class hierarchy
A common mistake is to see a switch and immediately create subclasses. First identify what actually varies.
For the sending operation, the variation is simple:
email -> send through email mechanism
sms -> send through SMS mechanism
push -> send through push mechanismThe smallest useful polymorphic interface could therefore be:
interface NotificationSender:
send(notification)with implementations:
EmailSender
SmsSender
PushSenderEach implementation answers the behavioral question directly:
class EmailSender:
function send(notification):
sendEmail(notification)
class SmsSender:
function send(notification):
sendSms(notification)The caller no longer selects behavior by inspecting a type code:
sender.send(notification)This is the core change in the mental model:
caller asks what type it has -> caller chooses behavior
becomes
caller asks for behavior -> selected object performs itPolymorphism moves the choice behind an interface. It does not make the choice disappear.
Put the selection in one deliberate place
Something still has to decide whether a request needs an EmailSender, SmsSender, or PushSender.
A factory or composition step can perform that selection:
function senderFor(channel):
switch channel:
case "email": return EmailSender()
case "sms": return SmsSender()
case "push": return PushSender()
default: raise UnknownChannel(channel)At first this can look pointless: there is still a switch.
The difference is its role. Before the refactoring, switches were distributed through business operations. Afterward, one boundary translates external data such as the string "sms" into an object that provides the required behavior. The rest of the application does not repeatedly interpret that string.
A type code often has to exist at an input or persistence boundary. JSON, configuration, command-line arguments, and database records cannot usually contain a live behavior object. The useful goal is therefore not “remove every conditional.” It is “avoid repeatedly branching on the same representation after the program has enough information to choose a behavior.”
Move one responsibility at a time
Do not migrate every type-based operation into a new abstraction at once. Start with one coherent responsibility.
Suppose retry delays also differ by channel:
function retryDelay(channel, attempt):
switch channel:
case "email": return attempt * 30
case "sms": return attempt * 10
case "push": return 5Should retryDelay become another method on NotificationSender?
Only if retry policy belongs to the same abstraction. If sending and retry policy change for different reasons, combining them can create an interface that collects unrelated behavior merely because both currently branch on channel.
A better question is:
Which behaviors form one responsibility from the caller’s point of view?
The answer may lead to one interface or several. Polymorphism improves locality only when the resulting abstraction is cohesive.
See how the change pattern improves
Assume sending has been moved behind NotificationSender and a new webhook channel is added.
The change becomes:
class WebhookSender:
function send(notification):
postWebhook(notification)and the composition boundary learns how to select it.
Existing callers continue to invoke:
sender.send(notification)They do not need another case "webhook" because they never interpret the channel value.
This is the practical benefit: when new variants are common and the supported operations are relatively stable, behavior can be extended by adding an implementation instead of editing every behavioral switch.
That trade-off has an opposite side. If new operations are added frequently across a stable set of variants, a centralized representation may be easier. With separate implementations, adding a new operation can require changing every implementation. Polymorphism changes which dimension of the design is easier to extend; it does not make all changes local.
Preserve behavior while refactoring
Replacing conditionals with polymorphism should be a structural change, not an opportunity to silently change rules.
Before moving a branch, identify its observable behavior. For the notification example, useful tests might establish that:
email notification -> email transport receives expected message
sms notification -> SMS transport receives expected message
push notification -> push transport receives expected message
unknown channel -> explicit errorThen move one branch at a time behind the interface while keeping those expectations unchanged.
Pay particular attention to the default branch. A switch may currently reject an unknown value, return a fallback, or do nothing. The new selection boundary must preserve that policy unless changing it is an intentional separate change.
Also check side effects and ordering. Two implementations that return the same value are not equivalent if one sends an event before persisting state and the other persists before sending it. The refactoring should preserve the externally relevant sequence where that sequence matters.
Avoid subclasses that exist only to remove syntax
Polymorphism has costs. More types mean more files or declarations, more navigation, and another abstraction for a reader to learn.
Consider this function:
function badgeFor(status):
switch status:
case "new": return "blue"
case "active": return "green"
case "archived": return "gray"If these are simply stable data mappings, creating NewStatus, ActiveStatus, and ArchivedStatus classes would likely obscure a simple relationship. A lookup table or the existing switch is easier to understand.
The distinction is useful:
same operation + different associated values -> data mapping may fit
same conceptual variant + different behavior -> polymorphism may fitEven behavioral differences do not automatically justify polymorphism. A single small switch with stable cases can remain the clearest design.
Watch for an interface that grows with every branch
Another failure mode is moving a large switch into an equally large interface:
interface OrderType:
calculatePrice()
validateAddress()
renderLabel()
chooseWarehouse()
selectEmailTemplate()
buildAnalyticsEvent()This may centralize the type distinction while creating implementations that know too much. Some methods may be unrelated, and some implementations may have meaningless methods only to satisfy the interface.
When that happens, the original switch may have revealed several independent dimensions that were incorrectly represented by one type code.
For example, warehouse selection might depend on fulfillment mode while email wording depends on customer communication preferences. Splitting those concepts can be more valuable than creating a broad OrderType hierarchy.
Use polymorphism to model a coherent behavioral role, not to hide every conditional behind one object.
Keep boundary data separate from behavioral objects
External representations often use strings, numbers, or enum-like values because they must cross process and storage boundaries. Those values need validation before they select behavior.
A useful flow is:
external value
-> validate and parse
-> select implementation
-> use behavioral interfaceDo not let an unrecognized value silently construct an arbitrary fallback unless fallback is an explicit product rule. An unknown type may indicate invalid input, incompatible persisted data, or a version mismatch. Handle that boundary condition deliberately.
Likewise, do not serialize implementation class names as if they were a stable domain protocol. Class names are code structure. External formats should use intentional, documented identifiers whose compatibility can be managed independently of refactoring.
Know when polymorphism is the right trade-off
Replacing a behavior switch is most useful when several conditions hold together:
- the same variant distinction drives behavior in multiple places;
- variants have meaningful behavior rather than only different constants;
- adding variants is a realistic source of change;
- callers can depend on a small, coherent behavioral interface;
- there is a sensible boundary where external type data can be translated into an implementation.
Keep the conditional when the cases are few, stable, and easiest to understand together. Prefer data when the branches merely map keys to values. Consider separate abstractions when one type code is carrying several unrelated responsibilities.
The goal is not to maximize the number of objects. The goal is to put knowledge where future changes require the least risky coordination while keeping the current code understandable.
Conclusion
Repeated type-based switches are expensive when one new variant requires coordinated edits across many operations. Polymorphism can reverse that dependency: select an implementation once, then let callers request behavior without repeatedly interpreting the type code.
Use the refactoring progressively. Identify one coherent behavior, preserve existing outcomes, move selection to a deliberate boundary, and keep unknown-value handling explicit. Then examine the resulting change pattern.
If the abstraction makes related behavior easier to find and new variants require fewer scattered edits, it is doing useful work. If it only replaces one readable switch with a collection of tiny types, the simpler conditional was probably the better design.