Code smellObject-Orientation Abusers
Switch Statements
The same switch or if-chain over a type code is repeated across the codebase, so adding one case means editing every copy.
- Also known as
- Repeated Switch, Type Switching
Signs
- Two or more functions switch on the same discriminator (
plan.tier,shape.kind,event.type) with the same set of cases. - Adding a new kind requires a search for every
case "pro":in the codebase. - A
default:branch throws "unknown type", which is the only thing protecting you from a forgotten case. - The switch lives in a class that does not own the data it switches on.
A single switch in one place, such as a factory or a parser, is not the smell. Duplication of the switch is.
Why it happens
A switch is the most direct way to express "behave differently for each kind", and the first one is fine. Then a second feature needs per-kind behavior, and copying the switch is faster than reorganizing the code. Each copy is reasonable on its own; together they scatter the definition of each kind across the codebase.
Example
Subscription tiers handled by switches in pricing, limits and display code.
type Tier = "free" | "pro" | "team";
function monthlyPriceCents(tier: Tier, seats: number): number {
switch (tier) {
case "free": return 0;
case "pro": return 1200;
case "team": return 900 * Math.max(seats, 3);
}
}
function storageLimitGb(tier: Tier): number {
switch (tier) {
case "free": return 2;
case "pro": return 100;
case "team": return 1000;
}
}
function badge(tier: Tier): string {
switch (tier) {
case "free": return "";
case "pro": return "PRO";
case "team": return "TEAM";
}
}Each tier becomes one object that owns all of its behavior. Adding an "enterprise" tier is one new entry, and the compiler flags any missing member.
interface Plan {
monthlyPriceCents(seats: number): number;
storageLimitGb: number;
badge: string;
}
const plans: Record<Tier, Plan> = {
free: { monthlyPriceCents: () => 0, storageLimitGb: 2, badge: "" },
pro: { monthlyPriceCents: () => 1200, storageLimitGb: 100, badge: "PRO" },
team: { monthlyPriceCents: (seats) => 900 * Math.max(seats, 3), storageLimitGb: 1000, badge: "TEAM" },
};
const price = plans[account.tier].monthlyPriceCents(account.seats);Treatment
- First isolate each switch with Extract Method, then use Move Method to put it on the class that owns the discriminator.
- If the discriminator is a type code fixed at creation, use Replace Type Code with Subclasses, then Replace Conditional with Polymorphism.
- If the type can change during the object's life (an account upgrades), use Replace Type Code with State/Strategy.
- If each case calls the same method with a different argument, use Replace Parameter with Explicit Methods.
- If one of the cases is "null" or "none", use Introduce Null Object.
- In TypeScript, Rust, Kotlin, Swift and Scala, a discriminated union plus an exhaustiveness check (
neverin the default branch, or amatch) makes a missing case a compile error. That can be enough on its own when the set of kinds is closed.
Payoff
- Each kind is defined in one place, so adding a kind is one change.
- Forgotten cases become compile errors instead of runtime exceptions.
- Per-kind behavior is testable without constructing the other kinds.
When to ignore
- There is exactly one switch over the type, typically in a factory that picks which object to create. That is where the decision belongs.
- The set of cases is closed and small, and the language checks exhaustiveness. A
matchover a sealed union is idiomatic in functional code and often clearer than a class hierarchy. - You add new operations far more often than new kinds. Polymorphism makes kinds cheap and operations expensive; the switch makes the opposite trade. For a closed set of kinds with many operations, see Visitor or keep the switches with exhaustiveness checks.
Agent checklist
- You counted the switches on the same discriminator; one switch alone is not a reason to refactor.
- You decided whether kinds or operations change more often before choosing polymorphism over exhaustive matching.
- The remaining single decision point (factory or lookup) is the only place that maps the raw code to an object.
- A test exists per kind for each behavior that moved.
- A missing case is now caught by the compiler or by an interface the new kind must implement.
Treatments
- Extract MethodMove a coherent fragment of a long function into its own well-named function and call it from the original place.
- Move MethodMove a method to the class whose data it uses most, and leave a delegating call or update the callers.
- Replace Type Code with SubclassesTurn each value of an immutable type code that drives conditionals into a subclass, and move the branch logic into overrides.
- Replace Type Code with State/StrategyMove behavior that depends on a type code into separate state or strategy objects that the host holds and can swap at runtime.
- Replace Conditional with PolymorphismReplace a switch on an object's type or kind with one class per case that overrides a shared method, so new cases add code instead of editing it.
- Replace Parameter with Explicit MethodsSplit a method that branches on a flag or enum argument into one clearly named method per value.
- Introduce Null ObjectReplace repeated null checks for a missing collaborator with an object that implements the same interface and does the safe default.
See also
- Primitive ObsessionDomain concepts like money, emails, IDs or statuses are passed around as raw strings and numbers, so their rules get re-checked everywhere.
- Shotgun SurgeryOne logical change forces small edits in many classes, so one missed edit ships an inconsistent system.
- Refused BequestA subclass inherits methods or data it does not want, overriding them to throw or do nothing, so the hierarchy lies about what it can do.
- Duplicate CodeThe same logic appears in two or more places, so every fix and change must be made several times and the copies drift apart.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.