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:
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 switchgetNotificationIcon()with the same switchvalidateNotification()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:
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:
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:
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
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
function initialize(env) {
if (env === 'production') {
setupProductionLogging();
} else {
setupDebugLogging();
}
}
This runs once at startup. No need for a pattern.
2-3 Simple Cases
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:
- Identify the abstraction — What do all cases have in common?
- Define the interface — What operations do all cases support?
- Create implementations — One class/object per case
- Create a factory — Returns the right implementation
- Replace the switch — Call the factory, then the method
Real-World Example: Payment Processing
Before:
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:
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.