Polymorphism

Imagine you are writing the software for a games console and you want a single button that says "Play". When it's pressed, a music file should play a song, a video file should show a film, and a game should launch the level. You do not want a giant tower of ifs asking "is this a song? is it a video? is it a game?" — you just want to say thing.play() and trust each thing to do the right version of "play".

That is polymorphism — from the Greek for "many forms". It means the same method call behaves differently depending on the actual type of the object it is called on. You write one line, shape.area(), and a circle computes its area one way while a rectangle computes it another — each object runs its own version of the method.

This is the pay-off of the object-oriented ideas you have already met. It rests directly on inheritance: a group of subclasses share a common supertype, each replaces a shared method with its own version, and your code talks to them all through that one shared type.

One shared type, many behaviours

Let's make it concrete with shapes. Every shape can report its area, but the formula is different for each. We declare a base class Shape that promises an area() method, then let each subclass override it — replace the inherited method with its own version. Press Run:

class Shape { area(): number { return 0; // a plain shape has no size of its own } describe(): string { return "A shape with area " + this.area().toFixed(2); } } class Circle extends Shape { constructor(private radius: number) { super(); } area(): number { // Circle's OWN version of area() return Math.PI * this.radius * this.radius; } } class Rectangle extends Shape { constructor(private width: number, private height: number) { super(); } area(): number { // Rectangle's OWN version of area() return this.width * this.height; } } console.log(new Circle(2).describe()); console.log(new Rectangle(3, 4).describe());

Look closely at describe(). It lives in Shape and is written once, yet it prints the correct area for both a circle and a rectangle. It calls this.area() without knowing or caring which subclass this really is — the object supplies the right version. That is polymorphism doing the work.

The magic: one loop, any shape

Here is where polymorphism truly earns its keep. We can put circles and rectangles into a single array whose declared type is Shape[], then loop over it calling area() on each. The variable shape is only ever declared as a Shape — but at runtime each element runs its own version:

class Shape { area(): number { return 0; } name(): string { return "Shape"; } } class Circle extends Shape { constructor(private r: number) { super(); } area(): number { return Math.PI * this.r * this.r; } name(): string { return "Circle"; } } class Rectangle extends Shape { constructor(private w: number, private h: number) { super(); } area(): number { return this.w * this.h; } name(): string { return "Rectangle"; } } class Triangle extends Shape { constructor(private base: number, private height: number) { super(); } area(): number { return 0.5 * this.base * this.height; } name(): string { return "Triangle"; } } // A mixed bag of shapes, all held through the common type Shape. const shapes: Shape[] = [ new Circle(1), new Rectangle(2, 5), new Triangle(4, 3), new Circle(3), ]; let total = 0; for (const shape of shapes) { // shape is DECLARED as Shape... const a = shape.area(); // ...but runs its REAL type's area() console.log(shape.name() + " has area " + a.toFixed(2)); total += a; } console.log("Total area of all shapes = " + total.toFixed(2));

The loop body has no if testing the kind of shape — no if (shape is Circle) … else if (shape is Rectangle) …. It works for circles, rectangles and triangles alike, and — crucially — it would work for a Pentagon or a Semicircle added years later, without changing a single line of this loop. New subtype in, correct behaviour out. That is the real power: code that is open to extension.

The same idea in a picture

Below, one message — area() — is sent to three different objects. Step through it: the call is identical each time, but it is dispatched to whichever version belongs to the object's real class. One interface, many implementations.

Why this beats a big if

Before polymorphism, the "many forms" job was done with a switch on a type tag — and it rots badly. Compare the two styles. The first must be edited every time a new shape appears; the second never needs touching:

// BEFORE — brittle: every new shape means editing this function function areaOf(shape: { kind: string }): number { if (shape.kind === "circle") return /* ... */ 0; if (shape.kind === "rectangle") return /* ... */ 0; if (shape.kind === "triangle") return /* ... */ 0; // ...and on, and on, forever return 0; } // AFTER — polymorphic: the shapes know their own area; this line never changes function areaOf(shape: Shape): number { return shape.area(); }

The knowledge of "how to find my area" lives inside each shape, exactly where it belongs, instead of piling up in one ever-growing function elsewhere. Adding a shape is now a matter of writing a new self-contained class — not surgery on old code that already works.

A second flavour: overriding a shared method

Shapes and areas are one example, but the pattern is everywhere. Here every Animal can speak(), and each species overrides it. A single function chorus() makes a whole zoo speak — and it works for any animal you invent:

class Animal { speak(): string { return "..."; } } class Dog extends Animal { speak(): string { return "Woof!"; } } class Cat extends Animal { speak(): string { return "Meow!"; } } class Cow extends Animal { speak(): string { return "Moo!"; } } function chorus(animals: Animal[]): void { for (const animal of animals) { console.log(animal.speak()); // each runs its own speak() } } chorus([new Dog(), new Cat(), new Cow(), new Dog()]);

Two similar-sounding words trip up almost everyone. They are different, and only one of them powers polymorphism.

The other classic trap: the version that runs is chosen by the object's real (runtime) type, not by the variable's declared type. In the snippet below, s is declared as a Shape, but it is really a Circle — so Circle's area() runs. The declared type only decides which methods you're allowed to call; the real object decides which version actually runs. Run it and see:

class Shape { area(): number { return 0; } } class Circle extends Shape { constructor(private r: number) { super(); } area(): number { return Math.PI * this.r * this.r; } } const s: Shape = new Circle(2); // declared Shape, really a Circle console.log(s.area()); // runs Circle.area() → ~12.57, NOT 0

Poly means "many" and morph means "form" — the same word root you meet in biology, where a species that comes in several forms is called polymorphic. In computing, the "one thing, many forms" is a method call: the single call shape.area() takes on the form of whichever concrete method the object provides. The technical name for the machinery that picks the right one at runtime is dynamic dispatch (sometimes "late binding") — the choice is made late, when the program runs, once the object's real type is known.