When you see a switch statement or long if/else chain that behaves differently based on type, it’s often a sign that polymorphism would serve you better. This refactoring replaces explicit type checking with object-oriented dispatch.

The Smell

// ❌ Type-based conditionals scattered everywhere
function calculateArea(shape: Shape): number {
  switch (shape.type) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    case 'triangle':
      return (shape.base * shape.height) / 2;
    default:
      throw new Error(`Unknown shape: ${shape.type}`);
  }
}

function draw(shape: Shape): void {
  switch (shape.type) {
    case 'circle':
      canvas.drawCircle(shape.x, shape.y, shape.radius);
      break;
    case 'rectangle':
      canvas.drawRect(shape.x, shape.y, shape.width, shape.height);
      break;
    case 'triangle':
      canvas.drawTriangle(shape.points);
      break;
  }
}

// Every new shape = modify EVERY switch statement

The Refactoring

// ✅ Each shape knows how to handle itself
interface Shape {
  area(): number;
  draw(canvas: Canvas): void;
  perimeter(): number;
}

class Circle implements Shape {
  constructor(
    private x: number,
    private y: number,
    private radius: number,
  ) {}

  area(): number {
    return Math.PI * this.radius ** 2;
  }

  draw(canvas: Canvas): void {
    canvas.drawCircle(this.x, this.y, this.radius);
  }

  perimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

class Rectangle implements Shape {
  constructor(
    private x: number,
    private y: number,
    private width: number,
    private height: number,
  ) {}

  area(): number {
    return this.width * this.height;
  }

  draw(canvas: Canvas): void {
    canvas.drawRect(this.x, this.y, this.width, this.height);
  }

  perimeter(): number {
    return 2 * (this.width + this.height);
  }
}

// Adding a new shape = add a new class, zero changes to existing code
class Triangle implements Shape {
  constructor(private points: [Point, Point, Point]) {}

  area(): number {
    const [a, b, c] = this.points;
    return Math.abs((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y)) / 2;
  }

  draw(canvas: Canvas): void {
    canvas.drawPolygon(this.points);
  }

  perimeter(): number {
    const [a, b, c] = this.points;
    return distance(a, b) + distance(b, c) + distance(c, a);
  }
}

// Client code is now simple and doesn't know about specific shapes
function renderShapes(shapes: Shape[], canvas: Canvas): void {
  for (const shape of shapes) {
    shape.draw(canvas);
    console.log(`Area: ${shape.area().toFixed(2)}`);
  }
}

Real-World Example: Payment Processing

// ❌ Before: conditional logic
function processPayment(payment: PaymentData): Receipt {
  if (payment.method === 'credit_card') {
    validateCard(payment.cardNumber);
    const fee = payment.amount * 0.029 + 0.30;
    return chargeCard(payment.amount + fee);
  } else if (payment.method === 'bank_transfer') {
    validateIBAN(payment.iban);
    const fee = 0.50;
    return initTransfer(payment.amount + fee);
  } else if (payment.method === 'crypto') {
    validateWallet(payment.walletAddress);
    const fee = payment.amount * 0.01;
    return sendCrypto(payment.amount + fee);
  }
  throw new Error('Unsupported payment method');
}

// ✅ After: polymorphic design
interface PaymentMethod {
  validate(): void;
  calculateFee(amount: number): number;
  execute(amount: number): Promise<Receipt>;
}

class CreditCardPayment implements PaymentMethod {
  constructor(private cardNumber: string) {}

  validate(): void {
    if (!luhnCheck(this.cardNumber)) {
      throw new ValidationError('Invalid card number');
    }
  }

  calculateFee(amount: number): number {
    return amount * 0.029 + 0.30;
  }

  async execute(amount: number): Promise<Receipt> {
    this.validate();
    const total = amount + this.calculateFee(amount);
    return stripe.charges.create({ amount: total });
  }
}

class BankTransferPayment implements PaymentMethod {
  constructor(private iban: string) {}

  validate(): void {
    if (!isValidIBAN(this.iban)) {
      throw new ValidationError('Invalid IBAN');
    }
  }

  calculateFee(_amount: number): number {
    return 0.50;
  }

  async execute(amount: number): Promise<Receipt> {
    this.validate();
    const total = amount + this.calculateFee(amount);
    return bankApi.initTransfer({ iban: this.iban, amount: total });
  }
}

// Clean client code
async function processPayment(method: PaymentMethod, amount: number): Promise<Receipt> {
  method.validate();
  return method.execute(amount);
}

When to Apply

  • Same switch/if appears in multiple places — strong signal
  • New types are added frequently — polymorphism makes this painless
  • Each type has distinct behavior — not just different data, but different logic
  • You’re violating OCP — modifying existing code to add new types

When NOT to Apply

  • Simple value mapping — a Record<string, number> is cleaner than a class hierarchy
  • One-off conditional — creating classes for a single if is overkill
  • Performance-critical paths — polymorphic dispatch has minimal overhead, but in hot loops, a direct branch might matter

“When you see a switch statement, think about polymorphism.” — Martin Fowler, Refactoring