Polymorphism
Handle object type variations through polymorphic types rather than conditional logic
TL;DR
The Polymorphism pattern encourages you to handle object type variations through inheritance and method overriding rather than through conditional logic. Instead of checking object types with if/else statements, you define a common interface that different types implement, allowing the correct behavior to be selected automatically based on the actual object type.
Learning Objectives
- Understand how polymorphism eliminates conditional type-checking
- Learn when to create polymorphic hierarchies
- Apply polymorphism to handle behavior variations elegantly
- Recognize and refactor code that uses conditional logic instead of polymorphism
- Balance polymorphism with simplicity and maintainability
Motivating Scenario
You're processing payments and have multiple payment methods: credit card, bank transfer, and digital wallet. You could write a PaymentProcessor with a massive if/else block checking the payment type. Or you could create a Payment interface with CreditCardPayment, BankTransferPayment, and DigitalWalletPayment implementations. Now each payment type knows how to process itself, and you can add new types without modifying existing code.
Core Concepts
Polymorphism is the ability of different objects to respond to the same message (method call) in their own way. The GRASP Polymorphism pattern advocates using polymorphic types and method overriding to handle variations in behavior, rather than embedding type-checking logic throughout your code.
Replacing conditional logic with polymorphism provides benefits:
- Extensibility: Add new types without modifying existing code (Open/Closed Principle)
- Maintainability: Each type encapsulates its own behavior
- Clarity: No scattered conditional logic; the code reads more naturally
- Testability: Each polymorphic type can be tested independently
- Type Safety: Compile-time checking in statically-typed languages
Without polymorphism, code filled with type checks becomes brittle. Adding a new type requires finding and modifying every if/else block. With polymorphism, new types are simply new implementations.
Practical Example
Let's see how to replace conditional logic with polymorphism:
- Python
- Go
- Node.js
from abc import ABC, abstractmethod
# WITHOUT POLYMORPHISM (avoid)
class BadPaymentProcessor:
def process_payment(self, payment_data: dict) -> bool:
if payment_data["type"] == "credit_card":
# Process credit card
print(f"Charging {payment_data['card_number']}")
return True
elif payment_data["type"] == "bank_transfer":
# Process bank transfer
print(f"Transferring from {payment_data['account']}")
return True
elif payment_data["type"] == "digital_wallet":
# Process digital wallet
print(f"Using {payment_data['wallet_id']}")
return True
return False
# WITH POLYMORPHISM (good design)
class Payment(ABC):
"""Base class defining payment interface"""
@abstractmethod
def process(self) -> bool:
pass
class CreditCardPayment(Payment):
def __init__(self, card_number: str, amount: float):
self.card_number = card_number
self.amount = amount
def process(self) -> bool:
print(f"Charging card {self.card_number}: ${self.amount}")
return True
class BankTransferPayment(Payment):
def __init__(self, account: str, amount: float):
self.account = account
self.amount = amount
def process(self) -> bool:
print(f"Transferring from {self.account}: ${self.amount}")
return True
class DigitalWalletPayment(Payment):
def __init__(self, wallet_id: str, amount: float):
self.wallet_id = wallet_id
self.amount = amount
def process(self) -> bool:
print(f"Using wallet {self.wallet_id}: ${self.amount}")
return True
class PaymentProcessor:
"""Works with any Payment implementation"""
def process_payment(self, payment: Payment) -> bool:
return payment.process()
# Usage
processor = PaymentProcessor()
# No type checking needed!
cc_payment = CreditCardPayment("4111-1111-1111-1111", 99.99)
processor.process_payment(cc_payment)
transfer = BankTransferPayment("ACC-12345", 150.00)
processor.process_payment(transfer)
wallet = DigitalWalletPayment("wallet-567", 49.99)
processor.process_payment(wallet)
package main
import "fmt"
// WITH POLYMORPHISM: Define common interface
type Payment interface {
Process() bool
}
type CreditCardPayment struct {
CardNumber string
Amount float64
}
func (cc *CreditCardPayment) Process() bool {
fmt.Printf("Charging card %s: $%.2f\n", cc.CardNumber, cc.Amount)
return true
}
type BankTransferPayment struct {
Account string
Amount float64
}
func (bt *BankTransferPayment) Process() bool {
fmt.Printf("Transferring from %s: $%.2f\n", bt.Account, bt.Amount)
return true
}
type DigitalWalletPayment struct {
WalletID string
Amount float64
}
func (dw *DigitalWalletPayment) Process() bool {
fmt.Printf("Using wallet %s: $%.2f\n", dw.WalletID, dw.Amount)
return true
}
type PaymentProcessor struct{}
func (pp *PaymentProcessor) ProcessPayment(p Payment) bool {
return p.Process()
}
func main() {
processor := &PaymentProcessor{}
// No type checking needed!
cc := &CreditCardPayment{"4111-1111-1111-1111", 99.99}
processor.ProcessPayment(cc)
transfer := &BankTransferPayment{"ACC-12345", 150.00}
processor.ProcessPayment(transfer)
wallet := &DigitalWalletPayment{"wallet-567", 49.99}
processor.ProcessPayment(wallet)
}
// WITH POLYMORPHISM: Define base class/interface
class Payment {
process() {
throw new Error("Must be implemented");
}
}
class CreditCardPayment extends Payment {
constructor(cardNumber, amount) {
super();
this.cardNumber = cardNumber;
this.amount = amount;
}
process() {
console.log(
`Charging card ${this.cardNumber}: $${this.amount.toFixed(2)}`
);
return true;
}
}
class BankTransferPayment extends Payment {
constructor(account, amount) {
super();
this.account = account;
this.amount = amount;
}
process() {
console.log(
`Transferring from ${this.account}: $${this.amount.toFixed(2)}`
);
return true;
}
}
class DigitalWalletPayment extends Payment {
constructor(walletId, amount) {
super();
this.walletId = walletId;
this.amount = amount;
}
process() {
console.log(
`Using wallet ${this.walletId}: $${this.amount.toFixed(2)}`
);
return true;
}
}
class PaymentProcessor {
processPayment(payment) {
// No type checking needed!
return payment.process();
}
}
// Usage
const processor = new PaymentProcessor();
const cc = new CreditCardPayment("4111-1111-1111-1111", 99.99);
processor.processPayment(cc);
const transfer = new BankTransferPayment("ACC-12345", 150.00);
processor.processPayment(transfer);
const wallet = new DigitalWalletPayment("wallet-567", 49.99);
processor.processPayment(wallet);
When to Use / When Not to Use
- When you have multiple related types that behave differently
- When adding new types should not require modifying existing code
- When the same operation has different implementations
- When you want to avoid scattered type-checking logic
- When behavior varies based on object type
- For simple single-type objects with no variation
- When polymorphic hierarchy requires too many levels
- When different behavior is based on state, not type
- When it adds unnecessary complexity to simple cases
- When simple conditionals are clearer than polymorphism
Patterns and Pitfalls
Polymorphism Implementation
Define clear contracts: Create interfaces or abstract classes that define the contract all implementations must follow. Each type knows how to fulfill that contract.
One responsibility per implementation: Each polymorphic type should encapsulate its own variation in behavior. Don't have one massive implementation that handles multiple cases internally.
Replace type checking gradually: Look for if/else blocks that check object type or type-like properties. Replace with polymorphism incrementally.
Deep inheritance hierarchies: Limit polymorphic hierarchies to 2-3 levels. Deep hierarchies become hard to understand and maintain.
Mixing state and type: If different behavior depends on internal state, not type, use Strategy pattern or state-based conditionals instead of type-based polymorphism.
Over-polymorphizing: Not every variation needs polymorphism. Simple, predictable variations might be fine as conditional logic or configuration.
Design Review Checklist
- Is there a clear interface or base type defining the contract?
- Does each concrete type implement this contract in its own way?
- Are there if/else blocks checking object type that could be replaced?
- Can new types be added without modifying existing code?
- Is the inheritance hierarchy shallow (2-3 levels max)?
- Are polymorphic types used where they appear, not passed through intermediate layers unnecessarily?
Self-Check
-
What's the main benefit of polymorphism? It allows you to add new types and behaviors without modifying existing code, making systems more extensible and maintainable.
-
When should you use polymorphism instead of conditionals? When you have multiple types that should behave differently and you expect to add new types in the future.
-
What's the danger of deep inheritance hierarchies? They become hard to understand, maintain, and modify. Keep polymorphic hierarchies shallow (typically 2-3 levels).
One Takeaway: Replace type-checking conditionals with polymorphic types. Let each type encapsulate its own behavior rather than having one controller check types and branch accordingly.
Next Steps
- Learn Low Coupling to keep polymorphic types loosely coupled
- Study High Cohesion to design focused polymorphic types
- Review Strategy Pattern for related behavioral variations
- Explore Template Method Pattern for shared polymorphic algorithms