LearnLLDOOPPolymorphism

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.

Your answer

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

The picture to keep
The horn

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.

In one line: Same call, different object, different behaviour. The caller never checks which.

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 days

Everything 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 door

checkout 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 bill

One new class. checkout was not opened. And if you forget refund, implements PaymentMethod refuses to compile, so there is no fifth ladder to miss.

Figure 1. checkout calls pay() on the interface. Which pay() runs depends on the object it was handed, and a new class slots in without checkout changing.

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 cash

Every 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.

ChoiceWhat you gainWhat you payPick it when
Interface and classesA 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 switchA 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.
Checking the type by hand

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.

Compile time versus runtime polymorphism

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.

TypeScript you just picked up
method: PaymentMethod
Holds any object that satisfies the interface. The call is resolved at runtime.
accounts.map((account) => account.monthlyFee())
Calls the right override for each element, with no type check.
{ kind: 'upi'; vpa: string } | { kind: 'cash' }
A discriminated union. The shared kind field tells the members apart.
switch (payment.kind)
Narrows the union. Inside each case only that member is visible.
const unhandled: never = payment
Exhaustiveness check. Compiles only if every kind has been handled.
method instanceof Card
A runtime class check. It works, and needing it is usually a design smell.

Checkpoint

Checkpoint

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?

Say this in 60 seconds

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.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.