Class Inheritance & Private Fields

A class can extend another class to reuse its code and add or change behaviour. And with #private fields and methods, a class can hide its internal details so outside code can only use the public interface you choose.

extends and super

JS extends.js
class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    return `${this.name} makes a sound`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  speak() {
    return `${this.name} barks`;
  }

  describe() {
    return `${super.speak()} — specifically, ${this.speak()}`;
  }
}

const rex = new Dog("Rex", "collie");
console.log(rex.speak());
console.log(rex.describe());
console.log(rex instanceof Animal);
Output
Rex barks
Rex makes a sound — specifically, Rex barks
true
  • class Dog extends Animal makes Dog inherit every method of Animal.
  • In a subclass constructor, super(...) calls the parent constructor. It must run before you use this.
  • Defining a method with the same name overrides the parent version. super.method() calls the parent's version.

If a subclass has no constructor, it automatically gets one that passes all arguments to super.

Forgetting super

JS no-super.js
class Base {
  constructor() { this.ready = true; }
}

class Child extends Base {
  constructor() {
    this.extra = 1;
  }
}

new Child();
Output
Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

Private fields and methods

Names starting with # are truly private: they can only be used inside the class body. Outside code can't read or even detect them:

JS private.js
class BankAccount {
  #balance = 0;
  #history = [];

  constructor(owner) {
    this.owner = owner;
  }

  deposit(amount) {
    this.#validate(amount);
    this.#balance += amount;
    this.#history.push(`+${amount}`);
  }

  withdraw(amount) {
    this.#validate(amount);
    if (amount > this.#balance) throw new Error("Insufficient funds");
    this.#balance -= amount;
    this.#history.push(`-${amount}`);
  }

  get balance() {
    return this.#balance;
  }

  #validate(amount) {
    if (!(amount > 0)) throw new Error("Amount must be positive");
  }
}

const acc = new BankAccount("Lee");
acc.deposit(100);
acc.withdraw(30);
console.log(acc.balance);
console.log(acc.balance = 1_000_000, acc.balance);
console.log(Object.keys(acc));
Output
70
1000000 70
[ 'owner' ]

The only way to change the balance is through deposit and withdraw, which validate every change. Assigning to balance has no effect because there's only a getter (in strict mode, including modules, it throws a TypeError instead), and the private fields don't appear in Object.keys. Writing acc.#balance outside the class is a syntax error.

Static inheritance and private static

JS static.js
class Shape {
  static #count = 0;

  constructor() {
    Shape.#count++;
  }

  static get count() {
    return Shape.#count;
  }
}

class Circle extends Shape {}
class Square extends Shape {}

new Circle();
new Square();
new Circle();
console.log(Shape.count, Circle.count);
Output
3 3

Static members are inherited too: Circle.count works because Circle's prototype chain includes Shape.

Extending built-in classes

You can extend built-ins like Error, Array or Map. Custom error classes are the most useful example (lesson 54):

JS custom-error.js
class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

try {
  throw new ValidationError("email", "Email is required");
} catch (err) {
  console.log(err instanceof ValidationError, err.name, err.field, err.message);
}
Output
true ValidationError email Email is required

Composition over inheritance

Inheritance models an "is a" relationship: a dog is an animal. Deep inheritance trees get rigid and hard to follow, though. Often it's better to build objects from smaller parts ("has a"):

JS composition.js
class Logger {
  log(msg) { console.log(`[log] ${msg}`); }
}

class OrderService {
  constructor(logger) {
    this.logger = logger;
  }
  place(item) {
    this.logger.log(`order placed: ${item}`);
  }
}

new OrderService(new Logger()).place("book");
Output
[log] order placed: book

Passing collaborators in like this also makes classes easy to test — you can swap in a fake logger.

Try it yourself

Try it yourself
Console output