Beginner12 min readObject-Oriented Foundationslive prototype

Polymorphism

One interface, many forms — the same method call does the right thing for each object's actual type, with no type-checking in the caller.

The idea

What it is

Polymorphism means many forms. The idea: you call one method by name, and each object responds in its own way — based on what it actually is. The caller doesn't need to know, or ask, which kind of object it's holding. It just says "do the thing," and the right behavior runs.

Think of a play button. Press it on a song and you hear audio; press it on a video and you see frames; press it on a podcast and the episode resumes. Same button, same word — play — but the behavior fits whatever media is loaded. Or imagine handing a stack of different shapes to someone and asking each one, "what's your area()?" A circle uses πr², a square uses s², a triangle uses ½bh — and you, the asker, never had to know the formula. Each shape knows its own.

The one sentence to remember

Write the call once against a shared interface; let each object supply its own version of the method. The same line of code, s.area(), runs different behavior depending on the object's real type.

Mechanics

How it works

Runtime polymorphism: overriding and dynamic dispatch

The kind that matters most in object-oriented design is runtime (or subtype) polymorphism. You define a shared base — an interface or abstract class — that declares a method like area(). Then each subtype overrides it with its own implementation. When you call shape.area(), the language looks at the object's actual type at runtime and picks the matching method. That lookup is called dynamic dispatch.

The crucial part: the variable's declared type can be the general one (Shape), but the behavior that runs is the specific one (Circle.area, Square.area). One call site, many possible behaviors, resolved while the program runs.

shapes.ts
interface Shape {
  area(): number; // the shared promise — every shape can do this
}

class Circle implements Shape {
  constructor(private r: number) {}
  area() { return Math.PI * this.r ** 2; } // its own formula
}

class Square implements Shape {
  constructor(private s: number) {}
  area() { return this.s ** 2; } // a different formula
}

const shapes: Shape[] = [new Circle(5), new Square(4)];
for (const s of shapes) {
  console.log(s.area()); // SAME call — dynamic dispatch picks the right one
}

The killer benefit: delete the type switch

The whole payoff is replacing a sprawling if/switch-on-type with a single polymorphic call. Look at the before: the caller has to know every shape and its formula, and you must edit it every time a new shape appears.

before-vs-after.ts
// BEFORE — the caller switches on a type tag (fragile)
function area(shape: any): number {
  switch (shape.kind) {
    case "circle":    return Math.PI * shape.r ** 2;
    case "square":    return shape.s ** 2;
    case "triangle":  return 0.5 * shape.b * shape.h;
    // add a new shape? you MUST come back and edit this switch.
    default: throw new Error("unknown shape");
  }
}

// AFTER — one polymorphic call; the caller knows nothing about types
function totalArea(shapes: Shape[]): number {
  return shapes.reduce((sum, s) => sum + s.area(), 0); // that's it
}

The after version never names a concrete shape. Add a Hexagon that implements Shape, and totalArea keeps working untouched. That's the open/closed principle in one breath: your code is open to new shapes, closed to modification.

The wider landscape (so you know the words)

  • Runtime / subtype polymorphism — overriding + dynamic dispatch, the one above. This is what people usually mean in OOP design.
  • Compile-time polymorphism (overloading) — several methods share a name but differ by their parameters, e.g. add(int, int) vs add(double, double). The compiler picks which one based on the arguments; nothing is decided at runtime.
  • Parametric polymorphism (generics / templates) — one piece of code works for many types without caring which, e.g. List<T> or Box<T>. The same logic, parameterized by type.

Dynamic dispatch in one line

When you write s.area(), the method that runs is chosen by s's actual type at runtime — not by the variable's declared type. That single rule is what makes the polymorphic loop possible.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A row of mixed Shape objects. Click any shape to call shape.area() on it — the dispatch bar shows the same call routing to that shape's own implementation (Circle.area(), Square.area(), …). Flip the toggle to perimeter() and click again — same idea, different method. Or press Run on all for the whole polymorphic loop. The caller never checks the type.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Click each shape

Click a Circle, then a Square, then a Triangle. The dispatch bar keeps showing the same call shape.area() on the left, but the right side changes — Circle.area(), Square.area(), Triangle.area(). One call site, a different method body each time. That's dynamic dispatch.

try 02

Flip the method, click again

Toggle the call from area() to `perimeter()` and click the shapes again. Every shape answers the new call in its own way — 2·π·r, 4·s, 3·s. Same interface, many forms: the whole point of polymorphism.

try 03

Add a shape — open/closed

Click + Add shape to drop a new type into the row, then click it (or press Run on all). It just works — the caller has zero if (circle) or switch (kind) to update. New behavior, no caller edits: the open/closed payoff.

In practice

When to use it — and what trips people up

Reach for polymorphism when…

  • You catch yourself writing a switch or if-chain on a type tag (if (kind === "circle")). That's the textbook smell — each branch wants to become an overridden method.
  • You have a family of things that do the same job differently — shapes with area(), payment methods with charge(), exporters with write().
  • You expect new variants over time and don't want every addition to ripple through caller code.

Where this is heading

Polymorphism is the engine behind whole design patterns. The Strategy pattern swaps interchangeable algorithms behind one interface; the State pattern lets an object change behavior when its internal state changes. Both are polymorphism with a name and a purpose — you'll meet them soon.

What it gives you

  • Extensible by default: add a new subtype without touching existing callers (open/closed).
  • Deletes type switches — no if/switch-on-kind scattered across the codebase.
  • Callers stay clean and general: they program to the interface, not to concrete types.
  • Each behavior lives next to the data it needs, so related logic is in one place.

Common mistakes

  • Indirection can hide control flow — "jump to definition" lands on an interface, not the code that actually runs.
  • Overusing it: a tiny one-off conditional doesn't need a class hierarchy and three new files.
  • LSP violations — an override that does something surprising (throws, no-ops, changes the contract) breaks callers that trusted the interface.
  • Too many thin subtypes can fragment logic, making the whole behavior hard to see at a glance.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

// One interface, many implementations. Pick your language above.
interface Shape {
  area(): number;
}

class Circle implements Shape {
  constructor(private r: number) {}
  area(): number { return Math.PI * this.r ** 2; }
}

class Square implements Shape {
  constructor(private s: number) {}
  area(): number { return this.s ** 2; }
}

// No type checks: one polymorphic call for every shape.
function totalArea(shapes: Shape[]): number {
  return shapes.reduce((sum, s) => sum + s.area(), 0);
}

const shapes: Shape[] = [new Circle(5), new Square(4)];
console.log(totalArea(shapes)); // 78.54 + 16 = 94.54

References & further reading

5 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Nothing is scored or saved.

question 01 / 04

In a loop that calls s.area() on a list of mixed Shape objects, what decides which area() actually runs?

question 02 / 04

What is the main advantage of replacing a switch-on-type with a polymorphic method call?

question 03 / 04

Which of these is compile-time (ad-hoc) polymorphism rather than runtime polymorphism?

question 04 / 04

A new Triangle subtype overrides area() but secretly returns 0 and logs a warning instead of the real area. Why is that a problem?

0/4 answered