All chapters in this track
- 01Chapter 1: Why Object Orientation Exists
- 02Chapter 2: Classes, Objects, and Instances, Precisely Defined
- 03Chapter 3: The Four Pillars I: Encapsulation
- 04Chapter 4: The Four Pillars II: Abstraction
- 05Chapter 5: The Four Pillars III: Inheritance
- 06Chapter 6: The Four Pillars IV: Polymorphism
- 07Chapter 7: Composition vs. Inheritance
- 08Chapter 8: Interfaces and Abstract Classes: When to Use Which
- 09Chapter 9: SOLID, Part 1: Single Responsibility & Open/Closed
- 10Chapter 10: SOLID, Part 2: Liskov Substitution, Interface Segregation, Dependency Inversion
- 11Chapter 11: Basic UML for Communicating Design
- 12Chapter 12: Anti-Patterns: God Objects, Tight Coupling, Deep Hierarchies, the Fragile Base Class, Anemic Domain Models
- 13Chapter 13: OOP Idioms Across TypeScript and Python — and Where OOP Is the Wrong Tool
- 14Chapter 14: When OOP Breaks Down: Real-World Failure Modes
- 15Chapter 15: Design Exercise: Parking Lot System
- 16Chapter 16: Design Exercise: Library Management System
- 17Chapter 17: Design Exercise: Elevator Controller
- 18Chapter 18: OOP in Interviews: What a Strong Answer Looks Like
Chapter 6 of 18
Chapter 6: The Four Pillars IV: Polymorphism
Why this matters
Polymorphism is the payoff pillar — it's the reason encapsulation, abstraction, and inheritance are worth building in the first place. Without it, you'd still end up writing the same conditional logic ("if it's a Dog, bark; if it's a Cat, meow") everywhere a type needs to be checked. Polymorphism is what lets you delete that conditional entirely.
The core concept
[BEGINNER] Polymorphism ("many forms") means code can work with objects of different types through a shared interface, with each type supplying its own behavior for the same call. The caller writes one line of code; which specific behavior runs is decided by the object's actual type at runtime.
Before — behavior selected by manual type-checking:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
} else if (shape.kind === "rectangle") {
return shape.width * shape.height;
}
throw new Error("Unknown shape"); // and every new shape needs this function updated
}
This works, but every new shape means finding and editing area() (and every other function that branches on kind). The branching logic is duplicated everywhere a shape's behavior is needed.
After — each type supplies its own behavior:
interface Shape {
area(): number;
}
class Circle implements Shape {
constructor(private radius: number) {}
area(): number { return Math.PI * this.radius ** 2; }
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
area(): number { return this.width * this.height; }
}
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, shape) => sum + shape.area(), 0); // no type-checking at all
}
const shapes: Shape[] = [new Circle(2), new Rectangle(3, 4)];
console.log(totalArea(shapes)); // works for any current or future Shape
totalArea never asks "what kind of shape is this?" It calls .area() and trusts each shape to know how to compute its own. Adding a Triangle later means writing a new class — totalArea doesn't change at all. This is the concrete payoff: new behavior is added by adding a new type, not by editing every function that branches on existing types.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Circle(Shape):
def __init__(self, radius: float) -> None:
self.radius = radius
def area(self) -> float:
return 3.14159 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
def total_area(shapes: list[Shape]) -> float:
return sum(shape.area() for shape in shapes)
Two kinds of polymorphism
[BEGINNER]
- Subtype polymorphism (the example above) — different classes implement a shared interface or extend a shared base class, and the caller works through that shared type. This is what "polymorphism" usually means in OOP discussions.
- Parametric polymorphism (generics) — a single implementation works uniformly across many types without needing to know anything about them, e.g.,
Array<T>or aList[T]works the same way whetherTis astringor aCircle. This is less about "different behavior per type" and more about "the same behavior, safely reused across types."
// Parametric polymorphism: one implementation, works for any T
function first<T>(items: T[]): T | undefined {
return items[0];
}
Most of this chapter — and most of what "polymorphism" means in interviews — is about subtype polymorphism, since that's what directly builds on encapsulation, abstraction, and inheritance/interfaces.
Static vs. dynamic dispatch — what actually happens at runtime
[SENIOR INSIGHT] The mechanism behind subtype polymorphism is dynamic dispatch: the decision about which method implementation runs is deferred until runtime, based on the object's actual type — not the type of the variable holding it.
class Animal {
speak(): string { return "..."; }
}
class Dog extends Animal {
speak(): string { return "Woof"; }
}
function makeItSpeak(animal: Animal): string {
return animal.speak(); // resolved at runtime based on the actual object, not the `Animal` type annotation
}
console.log(makeItSpeak(new Dog())); // "Woof" — Dog's override runs, even though the parameter type is Animal
This is different from static dispatch (resolved at compile time, based on the declared type) which most languages use for function overloading rather than for method calls on class hierarchies. The distinction matters because it explains why polymorphism works: makeItSpeak never needed to know it was holding a Dog — the runtime lookup did the work.
Common mistakes
- Writing a
switch/if-elsechain on a type tag when an interface with polymorphism would eliminate it. This is the single most common missed-polymorphism smell — if you see repeated branching on "what kind of X is this" scattered across a codebase, that's the exact shape the "before" example showed. - Violating Liskov Substitution in an override (covered in Chapter 5 and formally in Chapter 10) — an override that throws, narrows input types, or otherwise breaks what callers can safely assume about the base type breaks polymorphism's core promise: that any implementation is interchangeable through the shared interface.
- Reaching for inheritance just to get polymorphism, when an interface alone would do. Polymorphism doesn't require a class hierarchy — an interface implemented by otherwise-unrelated classes gives you polymorphism without any of inheritance's coupling costs (Chapter 8 develops this).
- Forgetting that polymorphism requires a genuinely shared contract. If each "implementation" of an interface secretly behaves so differently that callers need to know which one they have anyway, you haven't achieved polymorphism — you've just relocated the type-checking.
Checklist: using polymorphism well
- When you see repeated type-tag branching, consider replacing it with an interface + one implementing class per case. This is the direct fix for the "before" example's problem.
- Verify every implementation is truly substitutable before relying on polymorphism to dispatch between them — an implementation that secretly needs special-case handling defeats the point.
- Prefer an interface over a base class when the only goal is polymorphism, not shared implementation — it avoids inheritance's implementation-coupling cost entirely.
- Use parametric polymorphism (generics) for "same logic, any type," and subtype polymorphism for "different logic, shared contract." Don't reach for a class hierarchy to solve what a generic function already solves.
- When adding a new case (a new shape, a new payment method, a new notification channel), check whether you can do it by adding a new class rather than editing existing ones. If you can't, the polymorphism isn't actually doing its job yet.
Discussion / interview questions
-
"What is polymorphism, and what specific problem does it solve?" Strong answer: Lets a caller invoke behavior through a shared interface without knowing the concrete type, so new types can be added without modifying existing calling code. Uses a before/after example like the shape-area case, not just a definition.
-
"What's the difference between subtype polymorphism and parametric polymorphism (generics)?" Strong answer: Subtype polymorphism: different behavior per type, unified through a shared interface (dynamic dispatch). Parametric polymorphism: the same logic reused safely across many types (e.g., a generic container). Gives an example of each.
-
"How does dynamic dispatch actually work — why does calling a method on a variable typed as the base class run the subclass's override?" Strong answer: Explains that dispatch is resolved at runtime based on the object's actual type, not the variable's declared/static type — this is what makes substitutability possible.
-
"You see a function with a long
switchstatement branching on an object'stypefield. What would you consider instead, and why?" Strong answer: Recognizes it as a polymorphism opportunity — replace the tag with an interface, one implementing class per case, and let dynamic dispatch replace the branching; names the payoff (new cases added without touching the switch/existing code). -
"Can you get polymorphism without inheritance?" Strong answer: Yes — an interface implemented by otherwise-unrelated classes gives full subtype polymorphism, without the shared-implementation coupling that inheritance carries. Ties back to Chapter 5's point that inheritance is one way to get polymorphism, not the only way.
Chapter summary / cheat sheet
- Polymorphism lets callers invoke behavior through a shared interface without knowing the concrete implementation; new types are added by writing new classes, not by editing every place that branches on type.
- Subtype polymorphism (different behavior, shared contract) is resolved via dynamic dispatch at runtime, based on the object's actual type. Parametric polymorphism (generics) is "same logic, any type."
- A repeated
switch/if-elseon a type tag is the clearest signal that polymorphism should replace manual branching. - Polymorphism only requires a shared interface — it does not require inheritance or a shared base class.
- An override that isn't truly substitutable for the base type breaks polymorphism's core promise (this is Liskov Substitution, covered fully in Chapter 10).