Skip to content
aipatterns.dev

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.

plans.ts
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.

plans.ts
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

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 match over 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

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.