Emmanuel Akinfulubi

Lesson 11 of 1515 min read

Inheritance: extends and super

Building a new class on an existing one. Powerful, frequently overused, and the source of most bad OOP code ever written. You will learn both halves of that sentence.

By the end: Extend a class correctly, including its constructor.

A luxury coach and a danfo share a great deal: a plate number, an owner, a place in the fleet list. A luxury coach adds air conditioning and reclining seats. Rather than writing the shared parts twice, you build the specific thing on top of the general one.

extends, and the subclass gets everything

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

  describe() {
    return `${this.plate}, owned by ${this.owner}`;
  }
}

class LuxuryBus extends Bus {
  constructor(plate, owner, seats) {
    super(plate, owner);       // build the Bus part first
    this.seats = seats;
  }

  refit(n) {
    this.seats = n;
    return this;
  }
}

const lux = new LuxuryBus('LAG-441-XA', 'C. Okafor', 49);

console.log(lux.describe());     // inherited from Bus
console.log(lux.refit(45).seats);
console.log('own keys:', Object.keys(lux));
console.log('instanceof LuxuryBus:', lux instanceof LuxuryBus);
console.log('instanceof Bus:', lux instanceof Bus);

Output


      

What extends actually did

It linked two chains, one for instances and one for the classes themselves. The same idea from lesson 06, applied twice.

Two chains, both real

class Bus { describe() { return 'a bus'; } }
class LuxuryBus extends Bus {}

const lux = new LuxuryBus();

// Instance chain: lux -> LuxuryBus.prototype -> Bus.prototype -> Object.prototype
console.log(Object.getPrototypeOf(lux) === LuxuryBus.prototype);
console.log(Object.getPrototypeOf(LuxuryBus.prototype) === Bus.prototype);
console.log(Object.getPrototypeOf(Bus.prototype) === Object.prototype);

// Class chain, which is how static members are inherited.
console.log(Object.getPrototypeOf(LuxuryBus) === Bus);

// So describe is found two steps up.
console.log(lux.describe());
console.log('LuxuryBus.prototype owns describe?', Object.hasOwn(LuxuryBus.prototype, 'describe'));

Output


      

super, and the rule about calling it

The two ways to get this wrong

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

class TooEager extends Bus {
  constructor(plate) {
    try {
      this.seats = 49;          // before super: this does not exist yet
      super(plate);
    } catch (err) {
      console.log('1:', err.name + ':', err.message);
      super(plate);
    }
  }
}
new TooEager('a');

class Forgetful extends Bus {
  constructor(plate) {
    // no super() call at all
  }
}
try {
  new Forgetful('b');
} catch (err) {
  console.log('2:', err.name + ':', err.message);
}

Output


      

Omit the constructor entirely and one is supplied for you

class Bus {
  constructor(plate, owner) {
    this.plate = plate;
    this.owner = owner;
  }
  describe() { return `${this.plate} owned by ${this.owner}`; }
}

// No constructor here. JavaScript inserts constructor(...args) { super(...args); }
class Danfo extends Bus {
  get kind() { return 'danfo'; }
}

const d = new Danfo('ABJ-772-KM', 'unknown');
console.log(d.describe(), '|', d.kind);

Output


      

Overriding, and calling the original

Define a method with the same name and the subclass version wins, because lookup stops at the first match. super.method() reaches past your override to the parent's version.

Override, then extend rather than replace

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

  describe() {
    return `${this.plate}`;
  }

  prepare() {
    return ['check tyres', 'check oil'];
  }
}

class LuxuryBus extends Bus {
  constructor(plate, extra) {
    super(plate);
    this.extra = extra;
  }

  // Replaces the parent version.
  describe() {
    return `${super.describe()}, fitted with ${this.extra}`;
  }

  // Adds to the parent's list instead of rewriting it.
  prepare() {
    return [...super.prepare(), 'test the air conditioning', 'clean the seat covers'];
  }
}

const lux = new LuxuryBus('LAG-441-XA', 'air conditioning');
console.log(lux.describe());
console.log(lux.prepare());

Output


      

super inside callbacks

class Base {
  greet() { return 'base greeting'; }
}

class Child extends Base {
  arrowWorks() {
    return [1].map(() => super.greet());        // fine
  }

  methodWorks() {
    const parent = super.greet();               // capture it, then use it
    return [1].map(function () { return parent; });
  }
}

const c = new Child();
console.log(c.arrowWorks());
console.log(c.methodWorks());

Output


      

Three levels, and why that is usually too many

A deep chain works, and gets hard to read fast

class Vehicle {
  constructor(plate) { this.plate = plate; }
  describe() { return this.plate; }
}

class Bus extends Vehicle {
  describe() { return `${super.describe()} [bus]`; }
}

class LuxuryBus extends Bus {
  describe() { return `${super.describe()} [luxury]`; }
}

class SleeperBus extends LuxuryBus {
  describe() { return `${super.describe()} [sleeper]`; }
}

console.log(new SleeperBus('LAG-441-XA').describe());

// To know what one method does you now have to read four files.
let link = Object.getPrototypeOf(new SleeperBus('x'));
const chain = [];
while (link && link.constructor !== Object) {
  chain.push(link.constructor.name);
  link = Object.getPrototypeOf(link);
}
console.log('chain:', chain.join(' -> '));

Output


      

Extending built-ins

Subclassing Array and Error

class Fleet extends Array {
  get plates() { return this.map(b => b.plate); }

  ready() { return this.filter(b => b.ready); }
}

const fleet = new Fleet();
fleet.push({ plate: 'LAG-441-XA', ready: true });
fleet.push({ plate: 'ABJ-772-KM', ready: false });

console.log(fleet.plates);
console.log(fleet.ready());
console.log('still a real array:', Array.isArray(fleet), '| length:', fleet.length);

// Custom errors are the most useful built-in to extend.
class FullyBookedError extends Error {
  constructor(route) {
    super(`${route} is fully booked.`);
    this.name = 'FullyBookedError';
    this.route = route;
  }
}

try {
  throw new FullyBookedError('Lagos - Abuja');
} catch (err) {
  console.log(err.name + ':', err.message);
  console.log('catchable by type:', err instanceof FullyBookedError, err instanceof Error);
  console.log('carries data:', err.route);
}

Output


      

The test for whether inheritance is the right tool

A subclass that breaks its parent's promise

class Bus {
  constructor(plate, seats) {
    this.plate = plate;
    this.seats = seats;
  }
  refit(n) { this.seats = n; return this; }
}

// A danfo has fixed benches, not numbered seats. So this override lies.
class Danfo extends Bus {
  constructor(plate) { super(plate, 0); }

  refit() {
    throw new Error('A danfo has benches; it cannot be refitted with numbered seats.');
  }
}

function prepareAll(buses) {
  for (const b of buses) {
    try {
      b.refit(49);
      console.log('refitted', b.plate);
    } catch (err) {
      console.log('broke on', b.plate + ':', err.message);
    }
  }
}

prepareAll([new Bus('LAG-441-XA', 14), new Danfo('KAN-905-BD')]);

Output


      

prepareAll was written against Bus and is now wrong for reasons it cannot see. The honest fix is that Danfo is not a kind of Bus as Bus is currently defined. Either narrow Bus to the things all buses truly share, or use composition. Lesson 14 does both.

Your turn: extend it properly

Departure is given. Write NightBus extends Departure with three changes:

  • an extra escort property;
  • a describe() that calls super.describe() and adds the escort on the end;
  • a checklist() that adds two items to the parent's list without rewriting it.

One rule: NightBus must still work anywhere Departure works.

Editable. Fill it in, then run it.

Output


      

Recap

  • extends links two prototype chains: one for instances, one for the classes, which is how statics are inherited too.
  • In a subclass constructor, call super() before touching this, and call it at all.
  • An override wins because lookup stops at the first match; super.method() reaches the parent's version so you can extend rather than replace.
  • Keep chains to about two levels. Every level is another file a reader must hold in mind.
  • Extending Error is one of the most useful things inheritance does; a subclass that must throw from an inherited method is a sign inheritance was wrong.

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.