Appearance
9.2.5 — Polymorphism, Overriding and Overloading
The payments code from the 9.1 drill has six switch statements on the payment method. Here is one of them:
typescript
function authorize(order: Order, method: MethodKind): Promise<AuthResult> {
switch (method) {
case "card": return cardGateway.auth(order.total, order.cardToken);
case "upi": return upiGateway.collect(order.total, order.upiId);
case "wallet": return wallet.hold(order.customerId, order.total);
case "cod": return Promise.resolve(AuthResult.notRequired());
}
}And here is the same decision made again, in a different file, for capture. And again for refund. And again in the receipt renderer, the reconciliation job, and the admin panel.
The problem is not that switch is ugly. The problem is that the same decision is being made in six places, so adding PayPal means finding all six, and missing one compiles fine and fails at a real checkout.
Polymorphism is the mechanism that makes that decision exactly once.
1. What polymorphism is
Polymorphism is Greek for "many forms". In everyday object-oriented work it means one specific thing:
The same call runs different code depending on which object receives it.
typescript
await method.authorize(order); // which code runs? depends on what `method` isThat single line replaces all six switches. Write CardPayment, UpiPayment, WalletPayment and CodPayment, each implementing authorize, capture and refund, and the caller stops caring which one it holds:
typescript
interface PaymentMethod { // (1)
authorize(order: Order): Promise<AuthResult>;
capture(auth: AuthResult): Promise<Receipt>;
refund(receipt: Receipt, amount: Money): Promise<Refund>;
}
class CardPayment implements PaymentMethod { // (2)
constructor(private readonly gateway: CardGateway) {}
async authorize(order: Order) { return this.gateway.auth(order.total, order.cardToken); }
async capture(auth: AuthResult) { return this.gateway.capture(auth.id); }
async refund(receipt: Receipt, amount: Money) { return this.gateway.refund(receipt.id, amount); }
}
class CodPayment implements PaymentMethod { // (3)
async authorize(_order: Order) { return AuthResult.notRequired(); }
async capture(_auth: AuthResult) { return Receipt.pendingCollection(); }
async refund(_receipt: Receipt, amount: Money) { return Refund.manual(amount); }
}Line (1) is the role, exactly as 9.2.3 described it. Line (2) is one player. Line (3) is a player whose behaviour is completely different — cash on delivery does not talk to any gateway at all — and yet callers treat both identically.
The caller now reads:
typescript
const method = methods[order.methodKind]; // (1) one lookup, one place
const auth = await method.authorize(order); // (2) the object decides what happensLine (1) is the only place in the program that maps a string to a behaviour. Line (2) is polymorphism doing its job.
2. The sentence worth memorising: dispatch replaces selection
Here is the economic content of the whole idea.
Selection is the caller choosing what to do. It is a switch or an if, and it lives at the call site — so it is repeated at every call site, and every new variant means editing all of them.
Dispatch is the runtime choosing what to do, based on the object. It happens once, inside the language, and adding a variant means adding a class.
The consequence: new behaviour arrives as new code, and existing code is not edited. That is the Open/Closed Principle (9.3.6), and this is the machinery underneath it. It is also why Strategy (9.4.12) is the pattern you will use most — Strategy is this move, given a name.
3. How the runtime actually finds the method
Two mechanisms, same result.
In JavaScript and TypeScript: the prototype chain. method.authorize(order) makes the engine look for authorize on the object itself, then on its class's prototype, then on that class's parent prototype, and so on until found (3.6.4). This lookup is done at runtime, on every call, which sounds slow.
It is not, and knowing why is worth a minute. V8 caches the result at each call site, a technique called an inline cache. The first time a line executes, the engine records "objects with this shape find authorize at this address". Subsequent calls with the same shape skip the search entirely. When a call site only ever sees one shape, the engine can even inline the method body straight into the caller. A site that sees two or three shapes still caches all of them. A site that sees many shapes falls back to the full lookup (3.6.9 covers this properly). The practical effect: polymorphism costs nothing measurable in normal code, and only shows up in the hottest inner loops of a profile.
In Java, C# and C++: a virtual method table. Each class has a table of function pointers, and each object carries a pointer to its class's table. A call becomes: read the table pointer, read the slot, jump. Two memory reads and an indirect jump, no searching. It is fast enough that the entire language is built on it.
The design point is the same in both: the decision about which code to run belongs to the object, not to the caller, and the runtime does it for you at a cost you will almost never notice.
4. Overriding versus overloading — the classic interview question
These two words sound alike and are completely different. The difference is when the choice is made.
Overriding is runtime. A subclass replaces a method it inherited. Which one runs depends on the actual object:
typescript
class Payment {
describe(): string { return "a payment"; }
}
class CardPayment extends Payment {
override describe(): string { return "a card payment"; } // (1)
}
const p: Payment = new CardPayment(); // (2) the variable's type says Payment
console.log(p.describe()); // → "a card payment" (3)Line (1) overrides. Line (2) declares the variable as Payment, so at compile time TypeScript only knows it is a Payment. Line (3) prints the card version anyway, because the choice is made at runtime from the object, not at compile time from the variable's type. That is dynamic dispatch, and it is what makes section 1 and section 2 work.
Overloading is compile-time. Several functions share a name and differ by parameters, and the compiler picks one by looking at the arguments you wrote:
typescript
function refund(receipt: Receipt): Promise<Refund>; // (1)
function refund(receipt: Receipt, amount: Money): Promise<Refund>; // (2)
function refund(receipt: Receipt, amount?: Money): Promise<Refund> { // (3)
const toRefund = amount ?? receipt.total; // (4)
return gateway.refund(receipt.id, toRefund);
}Lines (1) and (2) are overload signatures. They have no bodies. They exist only for the type checker and for editor autocompletion. Line (3) is the single real implementation, and it must be compatible with every signature above it. Line (4) does the branching by hand, because it has to.
Here is the point that catches people, and it is worth stating flatly: TypeScript overloading is entirely a compile-time fiction. JavaScript has one function per name, full stop. The overload signatures vanish when the code is compiled. There is no runtime mechanism selecting between implementations by argument type, which is why line (3) has to inspect the arguments itself.
This is unlike Java or C++, where overloads really are separate methods and the compiler emits a call to a specific one. Even there, though, the selection happens at compile time from the declared types, which produces its own surprise:
java
void handle(Payment p) { System.out.println("payment"); }
void handle(CardPayment c) { System.out.println("card"); }
Payment p = new CardPayment();
handle(p); // → prints "payment", not "card"The variable is declared Payment, so the compiler picks the Payment overload, even though the object is a CardPayment. Overriding would have picked the card version. That contrast is the whole answer to the interview question: overriding looks at the object at runtime, overloading looks at the declared types at compile time.
| Overriding | Overloading | |
|---|---|---|
| Decided | at runtime | at compile time |
| Decided from | the object | the declared types |
| What varies | the body | the parameter list |
| Needs | inheritance or an interface | one class, one name |
| In TypeScript | real | signatures only, one body |
| Buys you | substitutability | nicer call sites |
Practical advice on overloads: reach for them sparingly. A union parameter (amount: Money \| undefined) or a well-named second function (refundFull, refundPartial) is usually clearer, since overloads force you to keep the signatures and the branching body in sync by hand. They earn their place when the return type depends on the arguments, which a union cannot express.
5. The other two polymorphisms
The word covers three things. Interviews usually mean the first, but knowing all three is what a complete answer looks like.
Subtype polymorphism is everything above: one call, many implementations, chosen by the object.
Parametric polymorphism is generics — one piece of code that works for any type, with the type supplied by the caller:
typescript
function first<T>(items: readonly T[]): T | undefined { // works for every T
return items[0];
}The code is identical for every T, which is exactly what makes it different from subtype polymorphism, where the code differs per type. 3.7.4 covers this fully.
Ad-hoc polymorphism is overloading, plus operator overloading in languages that have it. Different code per type, selected at compile time.
And there is a fourth thing worth naming because TypeScript leans on it heavily. Structural typing, sometimes called duck typing when it happens at runtime, means an object plays a role by having the right shape, with no declaration required:
typescript
const fake = { // not a class, implements nothing
authorize: async (_o: Order) => AuthResult.ok(),
capture: async (_a: AuthResult) => Receipt.test(),
refund: async (_r: Receipt, amt: Money) => Refund.of(amt),
};
await runCheckout(order, fake); // ✓ TypeScript accepts it: the shape matchesNothing here says implements PaymentMethod, and it does not need to (3.7.2). This is why test fakes in TypeScript are so cheap, and why you can retrofit an interface over classes written before it existed.
6. Classes or tagged unions? The trade-off that makes the senior answer
Here is the honest counterweight to everything above, and it is the part that separates a memorised answer from an understood one.
Polymorphism through classes is not free — it makes one kind of change cheap and a different kind expensive. The alternative is a discriminated union with exhaustive switch, and its costs are the mirror image.
typescript
interface PaymentMethod {
authorize(o: Order): Promise<AuthResult>;
capture(a: AuthResult): Promise<Receipt>;
}
// adding PayPal: one new file, zero edits elsewhere ✓
// adding a new operation `void()`: edit the interface AND every class ✗typescript
type Method =
| { kind: "card"; token: string }
| { kind: "upi"; vpa: string }
| { kind: "cod" };
function authorize(m: Method): Promise<AuthResult> {
switch (m.kind) {
case "card": return cardGateway.auth(m.token);
case "upi": return upiGateway.collect(m.vpa);
case "cod": return Promise.resolve(AuthResult.notRequired());
}
}
// adding `void()`: one new function, zero edits elsewhere ✓
// adding PayPal: every switch fails to compile until fixed ✗ (but the compiler lists them)This is known as the expression problem, and the summary is:
- Classes make adding a new type cheap and adding a new operation expensive.
- Tagged unions make adding a new operation cheap and adding a new type expensive.
The tagged-union side has one genuine advantage worth knowing: when you add a variant, TypeScript's exhaustiveness checking points at every switch that needs updating, so "expensive" means mechanical and complete rather than risky (3.7.3 shows the never trick that guarantees it).
Choose by which axis your domain actually grows. Payment methods grow by type — the operations authorize, capture and refund have been the same for decades, and new methods arrive every year. So classes. An abstract syntax tree grows by operation — the node types are fixed by the grammar, and you keep adding passes like type-check, optimise and print. So tagged unions, which is exactly what 3.11 used.
Being able to state this trade-off is the senior answer. Insisting that one is always right is the junior one.
7. Three ways polymorphism goes wrong
The instanceof chain. If your code looks like this, you have written a switch with extra steps and thrown away the benefit:
typescript
function fee(method: PaymentMethod): Money {
if (method instanceof CardPayment) return Money.of(200);
if (method instanceof UpiPayment) return Money.zero();
if (method instanceof WalletPayment) return Money.zero();
throw new Error("unknown method");
}The fix is to add fee() to the interface and let each class answer for itself. The occasional legitimate use of instanceof is at a boundary where you genuinely have an unknown value — error handling (9.2.4 section 5) and parsing external input — not inside your own domain.
Losing this when you pass a method around. This one bites everyone at least once:
typescript
const handler = method.authorize; // (1) the function, detached from its object
await handler(order); // → TypeError: Cannot read properties of undefinedLine (1) extracts the function value, and in JavaScript this is determined by how a function is called, not by where it was defined (3.6.3). Called bare, this is undefined, so the method cannot reach its own fields. The fixes are to wrap it ((o) => method.authorize(o)), to bind it (method.authorize.bind(method)), or to define the method as a class-field arrow function when it is designed to be passed as a callback.
Interfaces so wide nothing can implement them. If PaymentMethod had eleven methods and cash-on-delivery meaningfully supported three, you would get stubs that throw, which is the refused bequest smell from 9.2.4 arriving through interfaces instead of inheritance. Split the role: 9.3.8's Interface Segregation Principle.
What the next page adds. Polymorphism needs a declared role for callers to depend on, and there are two ways to declare one — an interface or an abstract class. 9.2.6 is about choosing between them, plus the enums that so often get used to represent the very variation this page has been replacing.
Recall
- Polymorphism means the same call runs different code depending on the receiving object. The economic content: dispatch replaces selection. A
switchrepeated at six call sites becomes one lookup and a class per variant, so new behaviour is new code rather than edits. - Mechanism: the prototype chain in JavaScript, a virtual method table in Java, C# and C++. V8's inline caches make repeated calls at one site nearly free, so the cost only matters in the hottest loops.
- Overriding is runtime, chosen from the object; overloading is compile time, chosen from the declared types. In TypeScript, overloads are signatures only — one real body that branches itself, because JavaScript has one function per name.
- Three polymorphisms: subtype (many implementations, one call), parametric (generics, identical code for any type), ad-hoc (overloading). Plus structural typing, where matching the shape is enough and no
implementsis needed, which makes test fakes almost free. - The expression problem: classes make new types cheap and new operations expensive; tagged unions do the opposite, with exhaustiveness checking listing every site to fix. Choose by which axis your domain grows.
- Failure modes:
instanceofchains inside your own domain (put the method on the interface instead), losingthiswhen a method is passed as a callback, and interfaces so wide that implementations must stub methods out.
Self-test: State the difference between overriding and overloading in one sentence about when. Why does TypeScript need one implementation body for several overload signatures? Name the two dispatch mechanisms and why the runtime cost is usually irrelevant. Give a domain that grows by type and one that grows by operation, and say which shape each wants. Why does const f = obj.method; f() throw?
Quiz Bank
FoundationalWhat is polymorphism, mechanically and economically?
Mechanically: one call site runs different code depending on the object that receives the call. In JavaScript the engine walks the prototype chain to find the method; in Java, C# and C++ it reads a virtual method table. Either way the decision is made at runtime from the object, not at compile time from the variable's declared type.
Economically, which is the part that matters for design: dispatch replaces selection. Without it, every caller makes the same choice itself, so the same switch appears at six call sites and every new variant means finding and editing all six — and missing one still compiles and fails in production. With it, the choice is made once when the object is created, and a new variant is a new class that no existing code has to know about.
That is the mechanism underneath programming to an interface, underneath the Open/Closed Principle (9.3.6), and underneath Strategy (9.4.12), which is exactly this move given a name.
A complete answer also names the other meanings of the word: parametric polymorphism, which is generics, where one piece of identical code works for any type; and ad-hoc polymorphism, which is overloading. In an object-oriented conversation, subtype polymorphism is the one being asked about.
FoundationalOverriding versus overloading — give the difference and one example where they disagree.
Overriding replaces an inherited method in a subclass, and which one runs is decided at runtime from the actual object. Overloading gives several functions the same name with different parameters, and which one runs is decided at compile time from the declared types.
The example where they visibly disagree, in a language with real overloads:
java
void handle(Payment p) { print("payment"); }
void handle(CardPayment c) { print("card"); }
Payment p = new CardPayment();
handle(p); // → "payment"The variable is declared Payment, so the compiler picks the Payment overload even though the object is a CardPayment. Had describe() been an overridden method called as p.describe(), the card version would have run, because overriding consults the object.
In TypeScript specifically, overloading is only signatures: you write several signature lines and exactly one implementation, and the implementation must inspect its own arguments and branch by hand. The signatures disappear at compile time, because JavaScript has one function per name and no runtime overload selection at all. So in TypeScript, overriding is a real runtime mechanism and overloading is a type-checker and autocomplete feature.
AppliedYou find a function with a chain of instanceof checks over your own domain classes. What is wrong and what is the fix?
What is wrong: the chain is a switch with extra steps, and it gives up everything polymorphism was providing. The knowledge of "what fee does each payment method charge" now lives outside the payment methods, so adding a method means finding this function and every function like it. Miss one and it either throws at runtime or silently falls through to a default, which is worse. The compiler cannot help, because adding a class does not break any instanceof chain.
It also breaks the deletion test from 9.1 section 6: removing a payment method should mean deleting one file, and instead it means hunting for every instanceof mentioning it.
The fix: put the operation on the interface and let each class answer for itself. fee(method) becomes method.fee(), and the branch disappears entirely rather than moving.
The objection you will hear, and the answer. Somebody will say the fee is a pricing concern and does not belong in a payment class. That is sometimes right, and when it is, the answer is not an instanceof chain — it is to make the varying thing explicit data: give each method a feePolicy it holds, or keep a Record<MethodKind, FeeRule> next to the pricing code where the whole table is visible in one place. Both keep the decision in one location; a chain of instanceof spread across the codebase does not.
When instanceof is legitimate: at boundaries where you genuinely receive something unknown. Error handling is the main one — catch (err) gives you unknown, and err instanceof InsufficientFundsError is the correct way to sort it. Parsing external input is another. What makes those different is that you are classifying a value from outside your control, not choosing behaviour among your own types.
InterviewWhen would you deliberately choose a switch over a discriminated union instead of polymorphic classes?
When the domain grows by operation rather than by type. This is the expression problem, and the two shapes have mirrored costs.
Classes make adding a type cheap: a new payment method is one new file and zero edits. They make adding an operation expensive: a new method on the interface means editing every implementing class.
Discriminated unions make adding an operation cheap: a new function with a switch over the existing variants, and nothing else changes. They make adding a type expensive: every existing switch needs a new case.
Why the expensive direction is much safer on the union side, which is the detail that makes the choice practical. With an exhaustiveness check — assigning the narrowed value to never in the default branch (3.7.3) — adding a variant makes every affected switch fail to compile, and the compiler hands you the complete list. So the work is mechanical and cannot be forgotten. Adding a method to an interface has the same property. The dangerous case is only the unchecked switch that falls through to a default.
Concrete calls. Payment methods, notification channels and shipping rules grow by type, and their operations are stable, so use classes. An abstract syntax tree, a state machine with a fixed set of states, or a protocol message set grows by operation over a set fixed by a grammar or a spec, so use tagged unions — which is exactly what the toy language in 3.11 does.
Two more practical reasons to pick unions: the data is coming from JSON and is plain data anyway, so wrapping it in classes adds a mapping step for nothing; or the whole set of variants and all the logic fits comfortably in one file, where one switch is genuinely easier to read than five small classes.
StaffA service handles 4 event types with a switch in 9 places. The team wants to convert to polymorphic handler classes. What do you check before agreeing, and how would you sequence it?
Check the growth axis first, because it decides whether this refactor pays. How many event types were added in the last year, and how many operations? If types are being added regularly and the operations are stable, classes are right. If the four event types come from an external specification that has not changed in three years, while the team keeps adding new things to do with them — validate, index, archive, replay — then the current shape is already correct, and converting would make the frequent change more expensive rather than less. Nine switches over a stable set of four is not automatically a problem.
Check whether the nine sites are really the same decision. Often three of them switch on the event type to pick a handler, which polymorphism fixes cleanly, while others switch to pick a serialiser or a routing key, which belong to different concerns and should not be pulled into the event classes. Merging genuinely different decisions into one hierarchy because they happen to branch on the same field produces a class that serves several masters, which is the false-DRY trap from 9.1 section 6.
Check for exhaustiveness today. If the switches already end in a never check, the compiler is listing every site whenever a type is added, so the current cost of a new type is mechanical rather than risky, and the refactor's benefit is smaller than the team thinks. If they end in silent defaults, the benefit is real and the risk is real.
Sequencing, assuming it is justified. Introduce the interface and the classes alongside the existing switches without deleting anything, then move one switch at a time to handler.handle(event), shipping each move separately so any regression is attributable to one small change. Convert the highest-churn site first, because that is where the benefit lands soonest and where the team will feel it. Keep one exhaustive switch at the edge, in the factory that maps incoming event data to handler objects — that is the single place the mapping should live, and having exactly one place is the entire goal. Delete the interface's stub methods honestly: if one event type cannot meaningfully implement an operation, that is a signal to split the interface (9.3.8) rather than to stub it.
What I would refuse: converting all nine in one pull request, and adding a class per event type for the two sites where the branch is about serialisation rather than behaviour.