Replace Conditional with Polymorphism
Learn to transform complex conditional logic into clean polymorphic designs using TypeScript interfaces and classes.
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
ifis 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