Skip to content
aipatterns.dev

Refactoring techniqueSimplifying Conditional Expressions

Replace Conditional with Polymorphism

Replace 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.

Problem

A conditional selects behavior by checking a type code, a kind field or instanceof. The same switch, with the same cases, appears in several functions. Each new kind forces you to find and edit every one of them.

Solution

Create a class (or an object implementing a shared interface) for each case, move each branch into an overriding method on its class, and replace the conditional with a single method call.

Before

TypeScript
type Notification = { channel: "email" | "sms" | "push"; to: string; body: string };

function send(n: Notification): Promise<void> {
  switch (n.channel) {
    case "email":
      return mailer.send({ to: n.to, subject: n.body.slice(0, 60), html: n.body });
    case "sms":
      return smsGateway.post(n.to, n.body.slice(0, 160));
    case "push":
      return pushService.notify(n.to, { alert: n.body });
  }
}

function costCents(n: Notification): number {
  switch (n.channel) {
    case "email": return 0;
    case "sms": return Math.ceil(n.body.length / 160) * 4;
    case "push": return 0;
  }
}

After

TypeScript
interface Channel {
  send(to: string, body: string): Promise<void>;
  costCents(body: string): number;
}

const email: Channel = {
  send: (to, body) => mailer.send({ to, subject: body.slice(0, 60), html: body }),
  costCents: () => 0,
};

const sms: Channel = {
  send: (to, body) => smsGateway.post(to, body.slice(0, 160)),
  costCents: (body) => Math.ceil(body.length / 160) * 4,
};

const push: Channel = {
  send: (to, body) => pushService.notify(to, { alert: body }),
  costCents: () => 0,
};

const channels = { email, sms, push } satisfies Record<string, Channel>;
// Callers: channels[n.channel].send(n.to, n.body)

Why

  • Everything about SMS lives in one object. Adding WhatsApp is one new object and one map entry.
  • Duplicated switches disappear, so no case can be forgotten in one of them.
  • Each variant can be tested alone.

Steps

  1. Find all conditionals that switch on the same discriminator. If there is only one, and it is short, stop here.
  2. Define the interface from the methods those conditionals implement.
  3. Create one class or object per case. Start with all methods delegating back to the old function, so behavior is unchanged. Run the tests.
  4. Move one branch body into its class method. Run the tests. Repeat branch by branch.
  5. Add the lookup (map, factory or constructor parameter) that picks the right implementation once, at the edge.
  6. Replace each switch with a call through the interface. Delete the old function when it has no callers. Run the tests.

Watch out

  • Do not apply this to a single switch that nothing else repeats. A switch with exhaustiveness checking (TypeScript never, Rust match, Java sealed types) is clear and safe.
  • If operations change more often than kinds, polymorphism makes things worse: each new operation touches every class. Keep a discriminated union and pattern matching, or use Visitor.
  • The switch moves, it does not vanish. Something still maps "sms" to the SMS object; keep that in one place.
  • When the kind of an object changes at runtime, use Replace Type Code with State/Strategy rather than subclasses.
  • No direct inverse technique exists. Collapsing polymorphic classes back into a switch is done manually with Inline Class and Collapse Hierarchy.

Agent checklist

  • The same discriminator is switched on in more than one place, or the single switch is long and growing.
  • Kinds change more often than operations.
  • The kind-to-implementation mapping exists in exactly one place.
  • Each branch moved separately with tests green between moves.

Treats these smells

See also

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