Emmanuel Akinfulubi

Lesson 14 of 1514 min read

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.

Two levels. Still fine.

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.

The diamond, and every escape from it

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.

Capabilities as parts

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.

Forwarding on purpose, not inheriting everything

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.

Two mixins, applied in a stack

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.

  1. Find the methods the subclass actually uses from the parent.
  2. Move the parent's data and those methods into a new standalone class.
  3. Give the old subclass a private field holding an instance of that class.
  4. Replace extends with that field, and forward only the methods callers genuinely need.
  5. Delete the parent if nothing else extends it.

Before: substitutability is broken

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


      

After: no lie, and the caller checks capability

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:

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.

Editable. Decide, implement, then run it.

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.

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.