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.
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
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().
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.
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
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.
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
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.
Output
Recap
- Polymorphism is many types answering the same call, each in its own way, so the caller stops asking what it has.
- A
switchon 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
toStringandSymbol.iteratorplugs your own types into the language's own polymorphic hooks.