DEV Community

Cover image for Replace Conditional with Polymorphism Explained: When and Why to Use It
Ibrahim H. Al-Yazouri
Ibrahim H. Al-Yazouri

Posted on

Replace Conditional with Polymorphism Explained: When and Why to Use It

If you open a file and immediately see a forest of if statements, switch es, and nesting that’s hard to follow, you’ve likely found a code smell. In object-oriented design, one powerful way to clean this up is Replace Conditional with Polymorphism — move the decision logic into different classes so each type knows how to behave.

Polymorphism lets a program call the correct implementation for an object even when the object’s concrete type is unknown in the current context. In many cases, you can eliminate conditionals and make your code clearer, easier to test, and easier to maintain.

This article will guide you step‑by‑step through why, when, and how to apply this refactor, with concrete examples and pitfalls to watch for.


Why bother?

Before we change anything, let’s agree on the benefits. Code with fewer conditionals is often:

  • Easier to read. Each class focuses on one behavior, so the call site no longer needs to understand branching logic.
  • Easier to test. Behavior is localized — you write focused unit tests for each concrete type instead of setting up many branching scenarios.
  • Easier to extend. Adding a new behavior usually means adding a new class instead of modifying existing switch/if logic.

That said, not every if is evil. Simple guards and primitive comparisons (>, <, ==) are fine. Our target is business logic branching where the system chooses different behavior based on type or a repeated condition.


When to replace conditionals with polymorphism

Use this refactor when you see either:

  • State-based behavior: An object’s behavior changes based on its type ore internal state.
  • Repeated conditionals: The same condition is checked in multiple places

Case 1 — State-based behavior

Suppose a method computes the speed of bird based on its type:

dobule getSpeed() {
  switch(type) {
    case EUROPEAN:
      return getBaseSpeed();
    case AFRICAN:
      return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
    case NORWEGIAN_BLUE:
      return isNailed ? 0 : getBaseSpeed(voltage);
  }

  throw new RuntimeException("Should be unreachable");
}
Enter fullscreen mode Exit fullscreen mode

Why this is a smell:

  • The behavior for each bird type is mixed in one place.
  • Adding a new bird requires changing this method.
  • Callers of getSpeed() must rely on a type field — they don’t work with a meaningful abstraction.

How to refactor?

  1. Introduce an abstract base class or interface Bird with an abstract getSpeed() method.
  2. Create subclasses: EuropeanBird, AfricanBird, NorwegianBlueBird.
  3. Move each branch of the switch into the corresponding subclass getSpeed() implementation.
  4. Replace usages of type + switch with polymorphic calls to bird.getSpeed().

Result: The decision logic travels to the types that own the behavior. Adding TropicalBird becomes a new class, not a modification. The call site becomes simpler and intention-revealing.

Another common example is an expression tree where a node can be either a value or an operator. A naive implementation that stores everything in one Node class often looks like this:

class Node {
    char operator; // '#', '+', '*', ...
    double value;
    Node left, right;


    double evaluate() {
      switch (operator) {
          case '#': return value;
          case '+': return left.evaluate() + right.evaluate();
          case '*': return left.evaluate() * right.evaluate();
          // add new cases for each operator
      }
      throw new IllegalStateException("Unknown operator");
    }
}
Enter fullscreen mode Exit fullscreen mode

Problems with this design:

  • The class holds fields that are irrelevant for some nodes (leaf nodes have left/right null; operation nodes have value unused).
  • The evaluate() method violates Open/Closed: every new operator requires modifying the switch.

Refactor steps:

  1. Create an abstract Node with abstract double evaluate().
  2. Create ValueNode that holds value and returns it from evaluate().
  3. Create an abstract OperationNode that holds left and right nodes.
  4. For each operation, create a concrete subclass: AdditionNode, MultiplicationNode, etc., each implementing evaluate().
abstract class Node {
  abstract double evaluate();
}

class ValueNode extends Node {
  double value;

  double evaluate() {
    return value;
  }
}

abstract class OperationNode extends Node {
  protected final Node left, right;
  OperationNode(Node left, Node right) { this.left = left; this.right = right; }
}


class AdditionNode extends OperationNode {
  AdditionNode(Node left, Node right) { super(left, right); }
  double evaluate() { return left.evaluate() + right.evaluate(); }
}


class MultiplicationNode extends OperationNode {
  MultiplicationNode(Node left, Node right) { super(left, right); }
  double evaluate() { return left.evaluate() * right.evaluate(); }
}
Enter fullscreen mode Exit fullscreen mode

After refactor: Each node contains only the fields it needs. Behavior is defined by the concrete class. Adding a new operator means adding a new class — no change to existing classes.

Now the behavior is distributed into specific classes. Adding a new operator is as simple as adding a new subclass — no modification of existing classes is necessary. This respects the Open/Closed Principle and eliminates irrelevant fields and nulls, making each class focused on a single responsibility.


Case 2 — Repeated conditionals

Consider code that branches based on a flag and repeats that branch in many methods:

class Update {
  void execute() {
    if (FLAG_A) { /* do A */ } 
    else { /* do B */ }
  }


  void render() {
    if (FLAG_A) { /* render A */ } 
    else { /* render B */ }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this is bad: The FLAG_A branching is duplicated — a single change will require updating multiple places.

Refactor steps:

  1. Introduce an abstract Update with abstract execute() and render().
  2. Create AUpdate and BUpdate subclasses that implement the appropriate behavior.
  3. Move the FLAG_A decision to a single place (factory, configuration loader, or composition root) that constructs either AUpdate or BUpdate.
abstract class Update {
  abstract void execute();
  abstract void render();
}


class AUpdate extends Update {
  void execute() { /* do A */ }
  void render() { /* render A */ }
}


class BUpdate extends Update {
   void execute() { /* do B */ }
   void render() { /* render B */ }
}
Enter fullscreen mode Exit fullscreen mode

Where does the if go? Construction. A factory or composition layer decides which concrete Update to instantiate based on the flag.

class UpdateFactory {
  static Update create(boolean flagA) {
    return flagA ? new AUpdate() : new BUpdate();
  }
}
Enter fullscreen mode Exit fullscreen mode

Result: The conditional is localized in the factory; all consumers use polymorphism and no longer repeat the if.


Guildlines and trade-offs

  • Use polymorphism when behavior differs by type/state or when the same conditional is repeated across methods
  • Keep code readable: using polymorphism should reduce complexity, not introduce a confusing class explosion.
  • Beware of runaway subclassing. If polymorphism would create dozens of tiny classes that are hard to manage, consider other patterns (strategy, composition, or data-driven approaches).
  • Not all conditionals should be removed. Simple guards and primitive comparisons are often fine.

Step-by-step refactoring checklist

  1. Identify repeated or branching logic that selects behavior by type/state.
  2. Create an abstraction (interface or abstract class) for the behavior.
  3. Move each branch into a dedicated concrete implementation.
  4. Replace call sites with polymorphic calls to the abstraction.
  5. Move conditional(s) to a single, well-documented place (factory/DI).
  6. Add tests for each concrete implementation.

Summary

Replacing conditionals with polymorphism often makes code more readable, extensible, and testable. The pattern helps you follow Single Responsibility and Open/Closed principles by moving behavior into dedicated types. When you see repeated switch/if logic, ask whether types can own that behavior — and whether doing so would simplify the system overall.


References

Examples adapted from: The Clean Code Talks — Inheritance, Polymorphism, & Testing by Google TechTalkshttps://youtu.be/4F72VULWFvc?si=v3kIuVEJSo29-TwJ

Top comments (0)