Composition Over Inheritance
The advice you will hear repeated forever without explanation. Here is the explanation, plus the failure it is meant to prevent, built and then fixed in front of you.
By the end: Choose between "is a" and "has a", and refactor from one to the other.
You will hear "favour composition over inheritance" repeated as if it were self-evident. It is not. Here is the failure it prevents, built in front of you, and then fixed.
Build the trap first
The park runs buses. Some have air conditioning. Some have a roof rack for luggage. Start with the obvious hierarchy.
class Bus {
constructor(plate) { this.plate = plate; }
describe() { return this.plate; }
}
class AirconBus extends Bus {
constructor(plate, kw) { super(plate); this.kw = kw; }
testCooling() { return `${this.plate}: testing ${this.kw}kW air conditioning`; }
describe() { return `${super.describe()} (${this.kw}kW aircon)`; }
}
class RackBus extends Bus {
constructor(plate, capacityKg) { super(plate); this.capacityKg = capacityKg; }
checkStraps() { return `${this.plate}: checking straps for ${this.capacityKg}kg`; }
describe() { return `${super.describe()} (${this.capacityKg}kg rack)`; }
}
console.log(new AirconBus('LAG-441-XA', 9).describe());
console.log(new RackBus('ABJ-772-KM', 400).describe());
Output
Now the park buys a bus that is both air-conditioned and fitted with a rack. Every option is bad.
class Bus {
constructor(plate) { this.plate = plate; }
}
class AirconBus extends Bus {
testCooling() { return 'testing the air conditioning'; }
}
class RackBus extends Bus {
checkStraps() { return 'checking the roof straps'; }
}
// Option A: extend one and copy the other's code. Now it exists twice.
class AirconRackBusA extends AirconBus {
checkStraps() { return 'checking the roof straps'; } // copied. Will drift.
}
// Option B: extend one and hand-wire the other. Also duplication, more subtle.
class AirconRackBusB extends RackBus {
testCooling() { return 'testing the air conditioning'; }
}
// Option C: extend both. Not possible in JavaScript at all.
// class AirconRackBusC extends AirconBus, RackBus {} // SyntaxError
const a = new AirconRackBusA('LAG-441-XA');
console.log(a.testCooling(), '/', a.checkStraps());
console.log('Both work, but checkStraps now lives in two classes.');
// And the next combination doubles the problem again:
// aircon + rack + toilet + television = eight classes.
console.log('Combinations needed for 4 optional features:', 2 ** 4);
Output
The fix: has-a instead of is-a
Stop asking "what kind of thing is this?" Ask "what parts does it have?" Each ability becomes its own small class, and a bus holds the ones it needs.
class AirCon {
#kw;
#on = false;
constructor(kw) { this.#kw = kw; }
get on() { return this.#on; }
test() { this.#on = true; return `testing ${this.#kw}kW air conditioning`; }
off() { this.#on = false; return 'air conditioning off'; }
}
class RoofRack {
#freeKg;
constructor(capacityKg) { this.#freeKg = capacityKg; }
get freeKg() { return this.#freeKg; }
check() { return `checking straps, ${this.#freeKg}kg free`; }
load(kg) {
if (kg > this.#freeKg) throw new RangeError(`Only ${this.#freeKg}kg free.`);
this.#freeKg -= kg;
return this;
}
}
class Bus {
#parts = new Map();
constructor(plate) { this.plate = plate; }
fit(label, part) { this.#parts.set(label, part); return this; }
has(label) { return this.#parts.has(label); }
part(label) {
const p = this.#parts.get(label);
if (!p) throw new Error(`${this.plate} has no ${label}.`);
return p;
}
// The preparation routine asks what it has, rather than what it is.
prepare() {
const steps = [];
for (const [label, part] of this.#parts) {
if (typeof part.test === 'function') steps.push(part.test());
if (typeof part.check === 'function') steps.push(part.check());
}
return steps.length ? steps : ['nothing to prepare'];
}
describe() {
return `${this.plate} [${[...this.#parts.keys()].join(', ') || 'plain'}]`;
}
}
const plain = new Bus('ABJ-772-KM');
const cooled = new Bus('LAG-441-XA').fit('aircon', new AirCon(9));
const both = new Bus('ENU-318-PH')
.fit('aircon', new AirCon(6))
.fit('rack', new RoofRack(400));
for (const b of [plain, cooled, both]) {
console.log(b.describe());
console.log(' ', b.prepare().join('; '));
}
// One new capability is one new class. No combinatorial explosion.
console.log('rack space left:', both.part('rack').load(120).freeKg);
Output
Delegation: forwarding on purpose
Composition sometimes means writing small forwarding methods. That feels like more typing than inheriting, and it is. What you buy is control over exactly which parts of the inner object are exposed.
class Ledger {
#entries = [];
add(label, naira) { this.#entries.push({ label, naira }); return this; }
get total() { return this.#entries.reduce((n, e) => n + e.naira, 0); }
get lines() { return this.#entries.map(e => `${e.label}: ₦${e.naira}`); }
clear() { this.#entries = []; return this; }
}
class Booking {
#ledger = new Ledger();
constructor(reference) { this.reference = reference; }
addSeat(seat) {
this.#ledger.add(`seat ${seat}`, 12000);
return this;
}
// Exposed on purpose.
get total() { return this.#ledger.total; }
get lines() { return this.#ledger.lines; }
// Note what is NOT exposed: Ledger.clear. A booking must not be wipeable.
}
const b = new Booking('BK-1').addSeat('1A').addSeat('1B');
console.log(b.lines, b.total);
console.log('can a caller clear it?', typeof b.clear);
Output
Compare that with class Booking extends Ledger. Extending would have handed every caller clear(), and there would be no way to take it back without breaking substitutability. Composition let you choose.
Mixins: sharing behaviour without a hierarchy
When several unrelated classes need the same methods, a mixin adds them. In JavaScript a mixin is a function that takes a class and returns an extended one.
const Serialisable = (Base) => class extends Base {
toJSON() {
const out = {};
for (const key of Object.keys(this)) out[key] = this[key];
out.type = this.constructor.name;
return out;
}
};
const Timestamped = (Base) => class extends Base {
constructor(...args) {
super(...args);
this.createdAt = '2026-08-23T10:00:00.000Z'; // fixed so output is stable
}
get age() { return 'created ' + this.createdAt; }
};
class Bare {
constructor(name) { this.name = name; }
}
class Departure extends Timestamped(Serialisable(Bare)) {
constructor(route, minutes) { super(route); this.minutes = minutes; }
}
const d = new Departure('Lagos - Abuja', 390);
console.log(JSON.stringify(d));
console.log(d.age);
console.log('chain:', d instanceof Departure, d instanceof Bare);
Output
Refactoring from inheritance to composition
The mechanical steps, in order. This is the refactor you will do most often on real code.
- Find the methods the subclass actually uses from the parent.
- Move the parent's data and those methods into a new standalone class.
- Give the old subclass a private field holding an instance of that class.
- Replace
extendswith that field, and forward only the methods callers genuinely need. - Delete the parent if nothing else extends it.
class Bus {
constructor(plate, seats) { this.plate = plate; this.seats = seats; }
refit(n) { this.seats = n; return this; }
describe() { return `${this.plate}, ${this.seats} numbered seats`; }
}
// A danfo has benches, so it has to lie about being a Bus.
class Danfo extends Bus {
constructor(plate) { super(plate, 0); }
refit() { throw new Error('A danfo has benches, not numbered seats.'); }
describe() { return `${this.plate}, bench seating`; }
}
function prepAll(list) {
for (const b of list) {
try { console.log(b.refit(49).describe()); }
catch (err) { console.log('failed:', err.message); }
}
}
prepAll([new Bus('LAG-441-XA', 14), new Danfo('KAN-905-BD')]);
Output
class SeatPlan {
#seats;
constructor(seats) { this.#seats = seats; }
get seats() { return this.#seats; }
refit(n) { this.#seats = n; return this; }
}
class Vehicle {
constructor(plate, seatPlan = null) {
this.plate = plate;
this.seatPlan = seatPlan; // null when there are no numbered seats
}
get numbered() { return this.seatPlan !== null; }
describe() {
return this.numbered
? `${this.plate}, ${this.seatPlan.seats} numbered seats`
: `${this.plate}, bench seating`;
}
}
function prepAll(list) {
for (const v of list) {
if (v.numbered) v.seatPlan.refit(49);
console.log(v.describe());
}
}
prepAll([
new Vehicle('LAG-441-XA', new SeatPlan(14)),
new Vehicle('KAN-905-BD'),
]);
console.log('nothing throws, and nothing pretends to have seats it lacks');
Output
When inheritance is still right
Composition is the default, not a law. Inheritance is the better tool when all of these hold:
- the relationship is genuinely "is a", and will stay that way;
- the subclass works everywhere the parent works, with no thrown overrides;
- there is one axis of variation, not two;
- you control both classes, so a change to the parent is not a change to someone else's code;
- the chain is one or two levels deep.
class FullyBookedError extends Error passes all five, which is why it is good code. class AirconRackBus extends AirconBus fails the third, which is why it is not.
Your turn: pick the relationship
For each pair below, decide whether it is "is a" (inheritance) or "has a" (composition), and say why in a comment. Then implement the two you are least sure about. There is a good answer for each, and one of them is genuinely arguable.
Output
Recap
- Inheritance classifies along a single axis; two independent axes of variation make the number of classes multiply.
- Say the relationship out loud: "is a" allows inheritance, "has a" demands composition.
- Composition means writing forwarding methods, and what you buy is control over exactly what callers can reach.
- Mixins share stateless behaviour across unrelated classes, but they are still inheritance and carry its costs.
- Inheritance remains right when the relationship is genuinely "is a", there is one axis, substitution holds, and the chain stays shallow.