Emmanuel Akinfulubi

Lesson 12 of 1512 min read

Polymorphism

The longest word in OOP and one of the simplest ideas: one call, many vehicles, each answering it in its own way.

By the end: Write code that handles many types through one interface.

Poly, many. Morph, form. Many forms answering to the same name.

A park attendant does the same thing to every vehicle in the fleet: waves it forward to load. What happens next depends entirely on which vehicle it is. How the passengers board, how many fit, all of it. The attendant does not need to know which one he is waving at.

The problem it solves

Here is code without polymorphism. It works, and every new type of thing makes it worse.

A growing chain of type checks

const fleet = [
  { kind: 'luxury', plate: 'LAG-441-XA', seats: 49 },
  { kind: 'coaster', plate: 'ABJ-772-KM' },
  { kind: 'danfo', plate: 'KAN-905-BD', benches: 4 },
];

function prepare(item) {
  if (item.kind === 'luxury') {
    return `${item.plate}: test the air conditioning, recline ${item.seats} seats`;
  } else if (item.kind === 'coaster') {
    return `${item.plate}: check the roof rack`;
  } else if (item.kind === 'danfo') {
    return `${item.plate}: tighten the ${item.benches} benches`;
  }
  return `${item.plate}: unknown type`;
}

for (const item of fleet) console.log(prepare(item));

// Add a sienna and you edit this function. Add a fourth type and you edit it again.
// Six functions shaped like this, and every new type means six edits.

Output


      

The same thing, the polymorphic way

Each type answers for itself

class Bus {
  constructor(plate) { this.plate = plate; }

  prepare() {
    return `${this.plate}: general check`;
  }
}

class LuxuryBus extends Bus {
  constructor(plate, seats) { super(plate); this.seats = seats; }
  prepare() { return `${this.plate}: test the air conditioning, recline ${this.seats} seats`; }
}

class Coaster extends Bus {
  prepare() { return `${this.plate}: check the roof rack`; }
}

class Danfo extends Bus {
  constructor(plate, benches) { super(plate); this.benches = benches; }
  prepare() { return `${this.plate}: tighten the ${this.benches} benches`; }
}

const fleet = [
  new LuxuryBus('LAG-441-XA', 49),
  new Coaster('ABJ-772-KM'),
  new Danfo('KAN-905-BD', 4),
];

// One line. It does not mention a single type name.
for (const b of fleet) console.log(b.prepare());

Output


      

Now adding a sienna means writing one new class. The loop above does not change, and neither does any other code that calls prepare().

Adding a type without touching the caller

class Bus {
  constructor(plate) { this.plate = plate; }
  prepare() { return `${this.plate}: general check`; }
}
class LuxuryBus extends Bus {
  constructor(plate, seats) { super(plate); this.seats = seats; }
  prepare() { return `${this.plate}: recline ${this.seats} seats`; }
}

// The new type. Nothing else in the program is edited.
class Sienna extends Bus {
  constructor(plate, rows) { super(plate); this.rows = rows; }
  prepare() { return `${this.plate}: check the ${this.rows} rows of seats`; }
}

function runThrough(fleet) {
  for (const b of fleet) console.log(b.prepare());
}

runThrough([new LuxuryBus('LAG-441-XA', 49), new Sienna('ENU-318-PH', 3)]);

Output


      

You do not need inheritance for this

Polymorphism is about the shape of the call, not about class hierarchies. Any two objects with a method of the same name are polymorphic with respect to that method. JavaScript calls this duck typing: if it responds to prepare(), it is preparable.

No common parent, still polymorphic

const bus = {
  name: 'LAG-441-XA',
  prepare() { return `${this.name}: check the tyres`; },
};

const pump = {
  name: 'diesel pump 2',
  prepare() { return `${this.name}: reset the meter`; },
};

class Gate {
  constructor(id) { this.name = `gate ${id}`; }
  prepare() { return `${this.name}: grease the hinges`; }
}

// Nothing here shares a class. The loop does not care.
for (const thing of [bus, pump, new Gate(2)]) {
  console.log(thing.prepare());
}

Output


      

Built-in polymorphism you already rely on

toString and Symbol.iterator are polymorphic hooks

class Money {
  constructor(kobo) { this.kobo = kobo; }
  toString() { return `₦${(this.kobo / 100).toFixed(2)}`; }
}

const fare = new Money(1200000);
console.log(`Fares from ${fare}`);          // template literal calls toString
console.log('One seat: ' + fare);           // so does concatenation
console.log([new Money(1200000), new Money(1800000)].join(' or '));

// Implement Symbol.iterator and for...of works on your own type.
class Fleet {
  #members = [];
  add(plate) { this.#members.push(plate); return this; }
  *[Symbol.iterator]() {
    for (const m of this.#members) yield m;
  }
}

const fleet = new Fleet().add('LAG-441-XA').add('ABJ-772-KM');
for (const member of fleet) console.log('in the fleet:', member);
console.log('spread works too:', [...fleet]);

Output


      

Overloading does not exist here

In Java or C# you can write two methods with the same name and different parameter lists. JavaScript has no such thing: the second definition simply replaces the first. You handle varying arguments inside one function.

The last definition wins

class Broken {
  book(n) { return `booked ${n} seats`; }
  book(n, from) { return `booked ${n} seats from ${from}`; }   // replaces the above
}

const b = new Broken();
console.log(b.book(3));           // the two-parameter version, with from undefined

// The JavaScript way: one method, flexible arguments.
class Fixed {
  book(n, from = 'the park') {
    if (typeof n !== 'number') throw new TypeError('book(n) needs a number.');
    return `booked ${n} seats from ${from}`;
  }
}

const f = new Fixed();
console.log(f.book(3));
console.log(f.book(3, 'the roadside stop'));

Output


      

A worked example: one renderer, four shapes

Polymorphism carrying real work

class Shape {
  get area() {
    throw new Error(`${this.constructor.name} must implement area.`);
  }
  describe() {
    return `${this.constructor.name.padEnd(9)} area ${this.area.toFixed(2)}`;
  }
}

class Rectangle extends Shape {
  constructor(w, h) { super(); this.w = w; this.h = h; }
  get area() { return this.w * this.h; }
}

class Square extends Rectangle {
  constructor(side) { super(side, side); }
}

class Circle extends Shape {
  constructor(r) { super(); this.r = r; }
  get area() { return Math.PI * this.r ** 2; }
}

class Triangle extends Shape {
  constructor(base, height) { super(); this.base = base; this.height = height; }
  get area() { return (this.base * this.height) / 2; }
}

const shapes = [new Rectangle(3, 4), new Square(5), new Circle(2), new Triangle(6, 4)];

for (const s of shapes) console.log(s.describe());

const total = shapes.reduce((sum, s) => sum + s.area, 0);
console.log('total area:', total.toFixed(2));

// Sorting, filtering, summing: none of it mentions a shape type.
console.log('largest:', shapes.slice().sort((a, b) => b.area - a.area)[0].constructor.name);

// And a shape that forgot to implement area says so clearly.
class Blob extends Shape {}
try {
  new Blob().describe();
} catch (err) {
  console.log('caught:', err.message);
}

Output


      

Your turn: replace the switch

The function below switches on a type string. Rewrite it as three classes with a shared cost() method, then make the total calculation a single loop that never mentions a type. Keep the numbers the same so you can check your answer.

Editable. Replace the switch, then run it.

Output


      

Recap

  • Polymorphism is many types answering the same call, each in its own way, so the caller stops asking what it has.
  • A switch on a type field, repeated across functions, is the smell that polymorphism is missing.
  • It does not need inheritance. Any objects sharing a method name are polymorphic for that call.
  • JavaScript has no method overloading; the last definition wins, so handle varying arguments inside one method.
  • Implementing toString and Symbol.iterator plugs your own types into the language's own polymorphic hooks.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.