Lesson 27 of 54

Control Flow and Logic

Replacing Conditionals with Polymorphism

Switch statements are often a code smell.

Not always—simple, stable switch statements are fine. But when you find yourself adding the same case to multiple switch statements, or when the switch keeps growing, it's time to consider polymorphism.

The Problem with Switch Statements

Consider a notification system:

Javascript
function sendNotification(notification, user) {
  switch (notification.type) {
    case 'email':
      const emailContent = formatEmailContent(notification);
      return emailService.send(user.email, emailContent);
    
    case 'sms':
      const smsContent = formatSmsContent(notification);
      return smsService.send(user.phone, smsContent);
    
    case 'push':
      const pushContent = formatPushContent(notification);
      return pushService.send(user.deviceToken, pushContent);
    
    default:
      throw new Error(`Unknown notification type: ${notification.type}`);
  }
}

Now imagine you have:

  • formatNotification() with the same switch
  • getNotificationIcon() with the same switch
  • validateNotification() with the same switch

Every time you add a new notification type, you modify multiple places. This violates the Open/Closed Principle: open for extension, closed for modification.

Solution 1: Strategy Pattern with Objects

Replace the switch with a lookup table of handlers:

Javascript
const notificationStrategies = {
  email: {
    format: (n) => formatEmailContent(n),
    send: (content, user) => emailService.send(user.email, content),
    validate: (n) => validateEmail(n),
    icon: 'mail',
  },
  sms: {
    format: (n) => formatSmsContent(n),
    send: (content, user) => smsService.send(user.phone, content),
    validate: (n) => validateSms(n),
    icon: 'message',
  },
  push: {
    format: (n) => formatPushContent(n),
    send: (content, user) => pushService.send(user.deviceToken, content),
    validate: (n) => validatePush(n),
    icon: 'bell',
  },
};

function sendNotification(notification, user) {
  const strategy = notificationStrategies[notification.type];
  if (!strategy) {
    throw new Error(`Unknown notification type: ${notification.type}`);
  }
  const content = strategy.format(notification);
  return strategy.send(content, user);
}

Now:

  • Adding a new notification type means adding one object
  • All related behavior is in one place
  • The send function doesn't need to change

Solution 2: Class-Based Polymorphism

For more complex scenarios, use classes:

Typescript
interface NotificationSender {
  format(notification: Notification): string;
  send(content: string, user: User): Promise<void>;
  validate(notification: Notification): ValidationResult;
  getIcon(): string;
}

class EmailNotificationSender implements NotificationSender {
  format(notification: Notification): string {
    return formatEmailContent(notification);
  }
  
  send(content: string, user: User): Promise<void> {
    return emailService.send(user.email, content);
  }
  
  validate(notification: Notification): ValidationResult {
    return validateEmail(notification);
  }
  
  getIcon(): string {
    return 'mail';
  }
}

class SmsNotificationSender implements NotificationSender {
  // ... same interface, different implementation
}

// Factory to get the right sender
function getNotificationSender(type: string): NotificationSender {
  const senders: Record<string, NotificationSender> = {
    email: new EmailNotificationSender(),
    sms: new SmsNotificationSender(),
    push: new PushNotificationSender(),
  };
  
  const sender = senders[type];
  if (!sender) {
    throw new Error(`Unknown notification type: ${type}`);
  }
  return sender;
}

// Usage
async function sendNotification(notification: Notification, user: User) {
  const sender = getNotificationSender(notification.type);
  sender.validate(notification);
  const content = sender.format(notification);
  await sender.send(content, user);
}

Solution 3: Registry Pattern

When you need even more flexibility:

Typescript
class NotificationRegistry {
  private senders = new Map<string, NotificationSender>();
  
  register(type: string, sender: NotificationSender): void {
    this.senders.set(type, sender);
  }
  
  get(type: string): NotificationSender {
    const sender = this.senders.get(type);
    if (!sender) {
      throw new Error(`No sender registered for type: ${type}`);
    }
    return sender;
  }
}

// At startup
const registry = new NotificationRegistry();
registry.register('email', new EmailNotificationSender());
registry.register('sms', new SmsNotificationSender());
// Plugins can register their own senders

This allows extension without modifying core code.

When Conditionals Are Fine

Don't over-engineer. Conditionals are appropriate when:

Simple, Stable Logic

Javascript
function getGreeting(hour) {
  if (hour < 12) return 'Good morning';
  if (hour < 17) return 'Good afternoon';
  return 'Good evening';
}

This is unlikely to change. Polymorphism would be overkill.

One-Time Decisions

Javascript
function initialize(env) {
  if (env === 'production') {
    setupProductionLogging();
  } else {
    setupDebugLogging();
  }
}

This runs once at startup. No need for a pattern.

2-3 Simple Cases

Javascript
function formatPrice(price, currency) {
  switch (currency) {
    case 'USD': return `$${price}`;
    case 'EUR': return `€${price}`;
    case 'GBP': return `£${price}`;
    default: return `${price} ${currency}`;
  }
}

Simple enough. Polymorphism would add complexity without benefit.

Signs You Need Polymorphism

  • Multiple switches on the same type — Same cases, different operations
  • Adding a case requires changes in multiple places — Shotgun surgery
  • The switch keeps growing — Every new feature adds a case
  • Difficult to test — You can't test cases in isolation

The Transformation

When you decide to refactor:

  1. Identify the abstraction — What do all cases have in common?
  2. Define the interface — What operations do all cases support?
  3. Create implementations — One class/object per case
  4. Create a factory — Returns the right implementation
  5. Replace the switch — Call the factory, then the method

Real-World Example: Payment Processing

Before:

Javascript
function processPayment(payment) {
  switch (payment.method) {
    case 'credit_card':
      validateCreditCard(payment);
      return chargeCreditCard(payment);
    case 'paypal':
      authenticatePayPal(payment);
      return chargePayPal(payment);
    case 'bank_transfer':
      validateBankAccount(payment);
      return initiateBankTransfer(payment);
  }
}

function getPaymentIcon(method) {
  switch (method) {
    case 'credit_card': return 'credit-card';
    case 'paypal': return 'paypal';
    case 'bank_transfer': return 'bank';
  }
}

After:

Typescript
const paymentProcessors: Record<string, PaymentProcessor> = {
  credit_card: {
    validate: validateCreditCard,
    charge: chargeCreditCard,
    icon: 'credit-card',
  },
  paypal: {
    validate: authenticatePayPal,
    charge: chargePayPal,
    icon: 'paypal',
  },
  bank_transfer: {
    validate: validateBankAccount,
    charge: initiateBankTransfer,
    icon: 'bank',
  },
};

function processPayment(payment: Payment) {
  const processor = paymentProcessors[payment.method];
  processor.validate(payment);
  return processor.charge(payment);
}

function getPaymentIcon(method: string) {
  return paymentProcessors[method].icon;
}

Key insight: When you see the same switch statement repeated in multiple places, or when adding a new case requires changes across the codebase, replace conditionals with polymorphism. But don't over-engineer—simple, stable conditionals are fine.