Polymorphism
Checkout has an if ladder: UPI, card, cash. So does refund. So does the receipt printer,
and so does the settlement report. Product asks for “pay later” by Friday. You find four
ladders and add a rung to each. In the retro you learn there was a fifth, in the
reconciliation job, and for a week every pay later order was reconciled as cash.
The ladders are all the same question asked again: what kind of thing am I holding? Polymorphism is how you stop asking.
You have UPI, card and cash payments, and four functions that each behave differently per payment type. A fifth type is coming. How would you arrange the code so that adding it means writing new code in one place?
The idea
Every vehicle on the road has one. A scooter, a truck and an auto each respond to the same press with their own sound. The driver does not check what they are driving before pressing it. The action is identical, and the response belongs to the vehicle.
The word means “many forms”. One method call, many possible behaviours, and the object receiving the call decides which.
Before: asking the type everywhere
function pay(kind: string, amount: number): string {
if (kind === 'upi') return `UPI request of ${amount} sent`
if (kind === 'card') {
return `Card charged ${amount + 10}, including a 10 rupee fee`
}
if (kind === 'cash') return `Collect ${amount} in cash at the door`
throw new Error(`Unknown payment kind: ${kind}`)
}
function refund(kind: string, amount: number): string {
if (kind === 'upi') return `UPI refund of ${amount}`
if (kind === 'card') {
return `Card refund of ${amount}, 5 to 7 working days`
}
if (kind === 'cash') return `Add ${amount} to wallet`
throw new Error(`Unknown payment kind: ${kind}`)
}
console.log(pay('card', 200)) // Card charged 210, including a 10 rupee fee
console.log(refund('card', 200)) // Card refund of 200, 5 to 7 working daysEverything about cards is spread across two functions, and soon across five. kind is a
plain string, so pay('crad', 200) compiles and blows up in front of a customer.
After: the object answers for itself
interface PaymentMethod {
pay(amount: number): string
refund(amount: number): string
}
class Upi implements PaymentMethod {
constructor(private readonly vpa: string) {}
pay(amount: number): string {
return `UPI request of ${amount} sent to ${this.vpa}`
}
refund(amount: number): string {
return `UPI refund of ${amount} to ${this.vpa}`
}
}
class Card implements PaymentMethod {
pay(amount: number): string {
return `Card charged ${amount + 10}, including a 10 rupee fee`
}
refund(amount: number): string {
return `Card refund of ${amount}, 5 to 7 working days`
}
}
class CashOnDelivery implements PaymentMethod {
pay(amount: number): string {
return `Collect ${amount} in cash at the door`
}
refund(amount: number): string {
return `Add ${amount} to wallet`
}
}
function checkout(method: PaymentMethod, amount: number): string {
return method.pay(amount)
}
const methods: PaymentMethod[] = [
new Upi('asha@okbank'),
new Card(),
new CashOnDelivery(),
]
for (const method of methods) {
console.log(checkout(method, 200))
}
// > UPI request of 200 sent to asha@okbank
// > Card charged 210, including a 10 rupee fee
// > Collect 200 in cash at the doorcheckout has one line and no if. The call method.pay(amount) is decided at runtime
by whichever object is in method. Everything about cards is now in one class, and you
can read it top to bottom.
Pay later arrives on Friday:
class PayLater implements PaymentMethod {
pay(amount: number): string {
return `${amount} added to your pay later bill`
}
refund(amount: number): string {
return `${amount} removed from your pay later bill`
}
}
console.log(checkout(new PayLater(), 200))
// > 200 added to your pay later billOne new class. checkout was not opened. And if you forget refund, implements PaymentMethod refuses to compile, so there is no fifth ladder to miss.
It works through inheritance too
Overriding, from the Inheritance page, is the same mechanism. A variable typed as the parent can hold any child, and the child’s version of the method is the one that runs.
class Account {
monthlyFee(): number {
return 100
}
}
class StudentAccount extends Account {
override monthlyFee(): number {
return 0
}
}
const accounts: Account[] = [new Account(), new StudentAccount()]
const fees = accounts.map((account) => account.monthlyFee())
console.log(fees) // [ 100, 0 ]The TypeScript alternative: a union and a switch
Classes are not the only way, and in TypeScript they are often not the best one. When the payment is plain data (it came from a database row or a JSON response), a discriminated union does the same job.
type Payment =
| { kind: 'upi'; vpa: string }
| { kind: 'card'; last4: string }
| { kind: 'cash' }
function receiptLine(payment: Payment): string {
switch (payment.kind) {
case 'upi':
return `Paid by UPI (${payment.vpa})`
case 'card':
return `Paid by card ending ${payment.last4}`
case 'cash':
return 'Paid in cash'
default: {
const unhandled: never = payment
return unhandled
}
}
}
console.log(receiptLine({ kind: 'card', last4: '4242' }))
// > Paid by card ending 4242
console.log(receiptLine({ kind: 'cash' })) // Paid in cashEvery member of the union has a kind with a different literal value. Inside
case 'card', TypeScript has narrowed payment to the card shape, so last4 exists and
vpa does not.
The default branch is the important part. never is the type with no values. If every
kind is handled, nothing is left and payment is never, so the assignment is fine. Add
{ kind: 'paylater' } to the union and that line stops compiling, in every switch that
forgot the new case. That is the missing fifth ladder from the top of the page, found by
the compiler.
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Interface and classes | A new kind is one new class, and no existing code is opened. All the behaviour of one kind sits together. | A new operation means editing every class. The objects have methods, so they do not travel as JSON. | New kinds arrive often, and each one carries its own state or dependencies. |
| Union and switch | A new operation is one new function. The data stays plain, so it goes to and from JSON with no conversion. | A new kind means visiting every switch, though the never check lists them for you. | The set of kinds is small and settled, and you keep adding things to do with them. |
If you write if (method instanceof Card) inside checkout, you have rebuilt the
ladder with extra steps. The fix is nearly always to move that branch into a method on
the interface, so the object does the work. An instanceof chain is the smell that
tells you a method is missing.
This is a Java question that gets asked in every language. Runtime polymorphism is
overriding: the object decides which method runs, as on this page. Compile time
polymorphism is overloading: several methods with one name and different parameters,
chosen by the compiler. Say that TypeScript has overload signatures but only one
implementation behind them, so in practice you use union parameters or generics. A
generic like first<T> from the TypeScript page is a
third kind, called parametric polymorphism.
method: PaymentMethodaccounts.map((account) => account.monthlyFee()){ kind: 'upi'; vpa: string } | { kind: 'cash' }switch (payment.kind)const unhandled: never = paymentmethod instanceof CardCheckpoint
1. checkout(method: PaymentMethod) calls method.pay(200). When is it decided which pay() runs?
2. In the union version, you add a fourth kind to Payment and forget to update receiptLine. What happens?
3. Your payment kinds have been the same three for two years, but you add a new report over them every month. Which style fits?
Polymorphism means one call does different things depending on the object that receives it, and the caller does not check which object it has. In practice I define an interface, give each kind its own class, and the code that used to be an if ladder becomes a single method call. A new kind is then one new class and nothing else is opened. It works the same through inheritance with overriding. In TypeScript there is a second option, a discriminated union with an exhaustive switch and a never check, which I prefer when the data is plain and the set of kinds is stable. The smell that tells me I need polymorphism is the same type check repeated in several functions.
