Inheritance

Mental model: extends links two chains at once: instances to the parent prototype, and the subclass itself to the parent class. super is how you step back up either one.

Level: advanced · about 16 minutes

class Animal {
  constructor(name) { this.name = name; }
  speak() { return `${this.name} makes a sound`; }
  static describe() { return 'an animal'; }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);                 // run Animal's constructor first
    this.breed = breed;
  }
  speak() {
    return `${super.speak()}, specifically a bark`;   // call the overridden method
  }
}

const rex = new Dog('Rex', 'collie');
console.log(rex.speak());        // 'Rex makes a sound, specifically a bark'
console.log(rex instanceof Dog, rex instanceof Animal);  // true true
console.log(Dog.describe());     // 'an animal' - statics are inherited too

Two keywords do all the wiring you used to do by hand.

Before extends, you wrote three fiddly lines: Dog.prototype = Object.create(Animal.prototype), then restored constructor, then called Animal.call(this, name) in the body. extends does all of it, plus one thing you could not do by hand: it links Dog itself to Animal, which is why Dog.describe() works.

The two chains

  rex ──> Dog.prototype ──> Animal.prototype ──> Object.prototype ──> null
                 (speak)          (constructor,
                                   speak)

  Dog ──> Animal ──> Function.prototype ──> Object.prototype ──> null
   (static members)   (static describe)

  extends builds BOTH arrows:
    Object.setPrototypeOf(Dog.prototype, Animal.prototype)   <- instance methods
    Object.setPrototypeOf(Dog, Animal)                       <- static members
class Base {}
class Sub extends Base {}

console.log(Object.getPrototypeOf(Sub.prototype) === Base.prototype); // true
console.log(Object.getPrototypeOf(Sub) === Base);                    // true
console.log(Sub.prototype.constructor === Sub);                      // true
console.log(new Sub() instanceof Base);                              // true

Check both links yourself.

super in a constructor

In a derived constructor, this does not exist until super() returns. The parent constructor is what creates the object, so touching this first is a ReferenceError. If you write no constructor at all, you get an implicit constructor(...args) { super(...args); }.

Broken: this before super

class Dog extends Animal {
  constructor(name) {
    this.legs = 4;   // ReferenceError:
    super(name);     // must call super
  }                  // first
}

Correct: super first, then this

class Dog extends Animal {
  constructor(name) {
    super(name);
    this.legs = 4;
  }
}

// No constructor needed at all if
// you only forward arguments.

Class fields on a subclass are installed immediately after super() returns, which is also why a field initialiser cannot see values the parent constructor assigns after that point. Keep parent constructors simple.

class Base {
  constructor() { this.tag = 'base'; }
}
class Derived extends Base {
  extra = 'field';                 // installed right after super() returns
  constructor() {
    super();
    console.log(this.tag, this.extra);   // 'base' 'field'
  }
}
new Derived();

class Forgetful extends Base {
  constructor() { /* no super() */ }
}
try { new Forgetful(); } catch (e) { console.log(e.constructor.name); } // ReferenceError

Overriding, and super in methods

A method on the subclass prototype shadows the parent one, because lookup stops at the first match. super.method() skips your own prototype and starts the search one level up, while still passing the current object as this.

class Repo {
  constructor() { this.log = []; }
  save(item) { this.log.push(`save ${item}`); return item; }
}

class AuditedRepo extends Repo {
  save(item) {
    this.log.push(`audit ${item}`);
    return super.save(item);        // same `this`, parent implementation
  }
}

const r = new AuditedRepo();
r.save('x');
console.log(r.log);                 // ['audit x', 'save x']
console.log(Object.getOwnPropertyNames(AuditedRepo.prototype)); // ['constructor', 'save']
class Model {
  static table = 'models';
  static describe() { return `table=${this.table}`; }
  static create(data) { return new this(data); }   // `this` is the subclass
  constructor(data) { this.data = data; }
}

class User extends Model {
  static table = 'users';
  static describe() { return `User(${super.describe()})`; }
}

console.log(User.describe());              // 'User(table=users)'
console.log(User.create({ id: 1 }) instanceof User); // true, thanks to `new this`
console.log(Model.describe());             // 'table=models'

super also works in statics, and it walks the class chain.

Abstract-ish base classes

JavaScript has no abstract keyword. You get the same effect with two cheap guards: refuse to construct the base directly by comparing new.target, and make unimplemented methods throw. The base can then call those methods, which is the template method pattern.

class Shape {
  constructor(name) {
    if (new.target === Shape) throw new TypeError('Shape is abstract');
    this.name = name;
  }
  area() { throw new Error(`${this.constructor.name} must implement area()`); }
  describe() { return `${this.name}: ${this.area().toFixed(2)}`; }
}

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

console.log(new Circle(2).describe());               // 'circle: 12.57'
try { new Shape('x'); } catch (e) { console.log(e.message); }        // 'Shape is abstract'
try { new Blob().describe(); } catch (e) { console.log(e.message); } // 'Blob must implement area()'

Extending built-ins

class Stack extends Array {
  peek() { return this[this.length - 1]; }
  static get [Symbol.species]() { return Array; }  // map/filter give plain Arrays
}

const s = new Stack();
s.push('a', 'b');
console.log(s.peek(), s.length);        // 'b' 2
console.log(Array.isArray(s));          // true
console.log(s.map((x) => x) instanceof Stack); // false, because of species
console.log(s instanceof Stack, s instanceof Array); // true true

Subclassing Array keeps the exotic length behaviour and the methods.

class AppError extends Error {
  constructor(message, options) {
    super(message, options);       // forwards { cause }
    this.name = new.target.name;   // 'AppError', or the subclass name
  }
}
class NotFound extends AppError {
  constructor(what) {
    super(`${what} not found`, { cause: new Error('lookup failed') });
    this.status = 404;
  }
}

const err = new NotFound('user');
console.log(err.name, err.status);              // 'NotFound' 404
console.log(String(err));                       // 'NotFound: user not found'
console.log(err instanceof NotFound, err instanceof AppError, err instanceof Error); // true true true
console.log(err.cause.message, typeof err.stack); // 'lookup failed' 'string'

The subclass you will actually write most often.

DetailWhat to doWhy
nameset it in the constructor, or use new.target.nameit defaults to "Error", so logs lie
messagepass it to super(message)the parent installs it non-enumerably
causepass { cause } as the second argumentES2022, keeps the original error attached
stacklet the engine set itV8 fills it in during super()
instanceofworks nativelyonly breaks in output transpiled to ES5
extra dataown properties after super()a status or field beats string-parsing the message
class Base {
  constructor() { this.value = this.init(); }
  init() { return 'base'; }
}
class Sub extends Base {
  suffix = '!';
  init() { return 'sub' + this.suffix; }
}
console.log(new Sub().value);

The parent constructor runs first, and it calls this.init(), which resolves dynamically to the subclass override. But subclass fields are only installed after super() returns, so this.suffix is still undefined at that moment. Calling overridable methods from a base constructor is a genuine design hazard, not just a quiz trick.

`super(...)`
only in a derived constructor, and before any use of this
`super.m()`
starts the lookup one level up, keeps the current this
Implicit constructor
constructor(...args) { super(...args); }
`new.target` in a base constructor
the most derived class that new was applied to
`Object.getPrototypeOf(Sub)`
Base, which is how statics are inherited
`Symbol.species`
which constructor built-in methods use for their results
Depth guideline
more than two levels and the next lesson becomes relevant

Inheritance is a promise that the subclass will keep working when the base changes. Make that promise carefully.

Every codebase with a five-level hierarchy

Try it yourself

Inspect both chains

class A { static who() { return 'A'; } hello() { return 'a'; } }
class B extends A { }
class C extends B { hello() { return 'c/' + super.hello(); } }

const c = new C();
console.log(c.hello());
console.log(C.who(), Object.getPrototypeOf(C) === B, Object.getPrototypeOf(B) === A);

const chain = (v) => {
  const out = [];
  for (let p = Object.getPrototypeOf(v); p; p = Object.getPrototypeOf(p)) {
    out.push(p.constructor?.name ?? '(none)');
  }
  return out.join(' -> ');
};
console.log('instance:', chain(c));
console.log('class:   ', chain(C));

Add a third level, then print the instance chain and the class chain side by side. Where does Object.prototype appear in each?

A usable error hierarchy

class AppError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = new.target.name;
  }
}
class ValidationError extends AppError {
  constructor(field, message) {
    super(message ?? `${field} is invalid`);
    this.field = field;
  }
}

const err = new ValidationError('email');
console.log(String(err), err.field);
console.log(err instanceof ValidationError, err instanceof AppError, err instanceof Error);
console.log(JSON.stringify(err));   // message is non-enumerable, so it is missing

Add a ConflictError with status 409, then write a handle(err) function that narrows with instanceof and falls back for unknown errors.

Exercises

A two-level error hierarchy

Build AppError extends Error and ValidationError extends AppError. new AppError(message, options) forwards both arguments to super so { cause } keeps working, and sets name to the name of the class actually being constructed (so a subclass reports its own name without repeating itself). new ValidationError(field, message) calls up with the message, defaulting to "<field> is invalid", and stores field as an own property. Add a static AppError.is(value) that returns true only for instances of the class it is called on.

An abstract shape base class

Write a base class Shape that cannot be constructed directly: new Shape("x") throws a TypeError, while subclasses construct fine. It stores name, declares area() which throws an Error when a subclass has not overridden it, and provides describe() returning "<name>: <area>" with the area formatted to two decimals via toFixed(2). Then write Circle extends Shape taking a radius, and Square extends Shape taking a side, whose describe() appends " (a special rectangle)" by reusing the base implementation rather than rebuilding the string.

Check yourself

What does this log?
'Bundefined' — Method lookup is dynamic, so the base constructor calls B.prototype.label. Field initialisers on B only run after super() returns, so this.suffix is still undefined when label reads it. Avoid calling overridable methods from a base constructor.
Why is this unavailable before super() in a derived constructor?
The base constructor is what creates the object, so there is nothing to bind until it returns — In a derived class, new delegates object creation up the chain. The binding for this stays uninitialised, like a const in its TDZ, until super() hands the object back down. Reading it early is a ReferenceError.
What does class Sub extends Base {} link, apart from the prototypes of the instances?
It sets Sub’s own prototype to Base, so static members are inherited through the class chain — There are two links: Sub.prototype -> Base.prototype for instance members, and Sub -> Base for statics. Nothing is copied, which is why adding a static to Base later is immediately visible on Sub. Sub.prototype.constructor still points at Sub.
Which guard makes a base class abstract without blocking subclasses?
if (new.target === Shape) throw new TypeError() — When you write new Circle(), the base constructor still runs but new.target is Circle, so the comparison only rejects direct construction of Shape. !new.target catches a call without new, which classes already reject, and the instanceof variant is true for every subclass instance too.

Common mistakes

  • Touching this before calling super() in a derived constructor.
  • Calling an overridable method from a base constructor, which runs before subclass fields exist.
  • Assuming statics are copied. They are inherited through a second live link from the subclass to the base.
  • Forgetting to set name on an Error subclass, so every log line says "Error".
  • Extending Array and being caught out by new Sub(3) meaning length 3, or by map returning the subclass.
  • Copying a method that uses super onto another object. super is fixed to where the method was defined.
  • Building a four-level hierarchy when a function argument would have done.

Takeaways

  • extends links instance prototypes and the classes themselves, so both methods and statics are inherited.
  • In a derived constructor the object comes from the base, so super() must run before this.
  • super.method() starts the lookup one level up while keeping the current receiver.
  • new.target === Base is the idiomatic abstract-class guard; new this(...) keeps static factories subclass friendly.
  • Subclassing Error needs a name, forwarded options for cause, and nothing else on modern engines.
  • Subclass fields are installed after super() returns, which makes base constructors calling overridable methods a hazard.