Polymorphism
Beginner · 10 min read · ▶ live playground · ✦ checkpoint
A museum gate has student, member, and evening tickets. The display receives each one as an AdmissionTicket, so a developer assumes every call must use the full-price method from that parent. Members start seeing a charge that their own ticket class says should be zero.
Polymorphism means one shared operation can produce different behavior depending on the object receiving the call. Subtype polymorphism is the form where a child object is used through a parent type, yet its override still runs.
The caller does not ask, “Which class are you?” It asks every ticket the same question: price().
One shared type, several answers
A subtype is a type that can be used where a more general type is expected. The children below are subtypes of AdmissionTicket because each one extends it and keeps its public surface.
class diagram
Every admission ticket answers price()
- 1AdmissionTicket gives callers one shared operation: ask this ticket for its price.
- 2StudentTicket is a subtype that overrides price() with the student amount.
- 3MemberTicket keeps the same operation but answers with the member amount.
- 4EveningTicket completes the family without changing how callers ask for a price.
The parent supplies the shared interface—the callable holder() and price() surface—even though this example uses a class rather than TypeScript's interface keyword. Each child keeps that surface and owns one pricing decision.
The solid arrows still describe inheritance. Polymorphism is what happens when code works through the type at the arrow's tip while the object at the other end supplies the behavior.
Call through the base type
A base type is the parent type used to describe a variable, parameter, or collection. A function that accepts AdmissionTicket can receive any of its subtypes without listing their names.
class AdmissionTicket {
private holderName: string;
public constructor(holderName: string) {
this.holderName = holderName;
}
public holder(): string {
return this.holderName;
}
public price(): number {
return 24;
}
}
class StudentTicket extends AdmissionTicket {
public override price(): number {
return 12;
}
}
class MemberTicket extends AdmissionTicket {
public override price(): number {
return 0;
}
}
class EveningTicket extends AdmissionTicket {
public override price(): number {
return 16;
}
}
class GateDisplay {
public lineFor(ticket: AdmissionTicket): string {
return `${ticket.holder()}: $${ticket.price()}`;
}
}
const display = new GateDisplay();
const tickets: AdmissionTicket[] = [
new StudentTicket("Mina"),
new MemberTicket("Omar"),
new EveningTicket("Leena"),
];
for (const ticket of tickets) {
console.log(display.lineFor(ticket));
}Mina: $12 Omar: $0 Leena: $16
GateDisplay.lineFor() is written once. Its parameter says only AdmissionTicket, and the array also stores AdmissionTicket references. Neither place contains an if for students, members, or evening visitors.
The objects still remember what they actually are. A variable's runtime type is the concrete class of the object created with new. Mina's variable has the base type AdmissionTicket, but its runtime object is a StudentTicket.
Dynamic dispatch is the runtime choosing the most specific override for the receiving object. When ticket.price() runs for Mina, dynamic dispatch selects StudentTicket.price(). For Omar, the identical source line selects MemberTicket.price().
Watch the same call reach different objects
The sequence diagram shows two calls from the same caller. The message label stays price(); the receiver's runtime type changes.
sequence diagram
The gate asks two tickets the same question
- 1GateDisplay sends price() through a base-typed reference to the StudentTicket object.
- 2Dynamic dispatch selects StudentTicket.price(), and the student amount returns.
- 3The same source call reaches MemberTicket when the runtime object changes.
- 4MemberTicket's override returns $0 while GateDisplay remains unchanged.
Polymorphism moves the varying decision behind the shared operation. GateDisplay owns display formatting. Each ticket owns its price. Adding another valid ticket subtype does not require teaching the display how that subtype calculates.
That separation works only when the subtypes mean the same thing by the operation. Every price() returns this ticket's admission price. A child that returned the cost of parking would share the method name but violate the shared idea.
When not to reach for polymorphism
A type tag is a field such as kind: "student" | "member" that tells outside code which variant it holds. Repeated branches on a type tag are a smell when the branch selects behavior the object could own:
type TaggedTicket = {
holder: string;
kind: "standard" | "student" | "member";
};
function priceFor(ticket: TaggedTicket): number {
if (ticket.kind === "student") {
return 12;
}
if (ticket.kind === "member") {
return 0;
}
return 24;
}If price branches appear in the gate, receipt, website, and report, every new ticket kind makes you find and update all of them. Polymorphic ticket objects can keep that behavior in one place per subtype.
The smell is repetition and scattered ownership, not the existence of if. A boundary that converts raw form data into a ticket may need one branch to decide which object to create. That branch makes a choice; it does not recalculate ticket behavior on every call.
Polymorphism can also be too much structure. If you have two stable cases, one short calculation, and no caller benefits from a shared object type, a small conditional may be clearer than three classes. Start with the variation you actually have. Introduce subtypes when several callers need the same contract or when each variant owns meaningful behavior.
Checkpoint
Answer all three to mark this lesson complete
You can now send one message through a base type and trust each runtime object to answer in its own way. Next, composition gives you another route to varied behavior: assemble an object from swappable parts instead of growing a family tree.