Skip to editor content
learningjavascript.orglesson 20 of 25

Classes and Inheritance

JavaScript's class syntax gives you a clean, familiar way to define reusable object blueprints. Under the hood, classes are still built on the prototype system JavaScript has always used — but the syntax makes it much easier to reason about structure, share behaviour across instances, and extend existing types.

This lesson covers the full lifecycle of a class: defining it, instantiating it, adding methods, inheriting from it, and using modern features like static methods and private fields.

Defining a Class

A class is a template for creating objects. You define the initial state inside a special constructor method, and you add shared behaviour as regular methods on the class body.

class Animal {
  constructor(name, sound) {
    this.name = name;
    this.sound = sound;
  }

  speak() {
    return `${this.name} says ${this.sound}!`;
  }

  describe() {
    return `I am ${this.name}.`;
  }
}

const cat = new Animal("Whiskers", "meow");
const dog = new Animal("Rex", "woof");

console.log(cat.speak());
console.log(dog.speak());
console.log(cat.describe());

// All instances share the same methods — they are not copied per object
console.log(cat.speak === dog.speak); // true

Each call to new Animal(...) creates a fresh object. The constructor receives the arguments you pass to new and sets them as properties on this. Methods defined in the class body are placed on the prototype, so they are shared across every instance rather than duplicated.

Inheritance with extends

The real power of classes comes when you need specialised versions of a base type. The extends keyword creates a child class that inherits all the methods of its parent.

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

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

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // must call super before accessing this
    this.breed = breed;
  }

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

  fetch(item) {
    return `${this.name} fetches the ${item}!`;
  }
}

class Cat extends Animal {
  speak() {
    return `${this.name} meows.`;
  }
}

const dog = new Dog("Rex", "Labrador");
const cat = new Cat("Luna");

console.log(dog.speak());   // overridden method
console.log(cat.speak());   // overridden method
console.log(dog.fetch("ball")); // Dog-only method
console.log(dog instanceof Dog);    // true
console.log(dog instanceof Animal); // true — also an Animal

Three things to notice here:

  • super(name) calls the parent constructor. You must call super before you use this in a child constructor.
  • Dog overrides speak with its own version. The parent's version is no longer called unless you explicitly invoke it.
  • instanceof checks the full inheritance chain, so a Dog is also an Animal.

Predict

The parent defines greet, which calls this.speak(). The child overrides speak but NOT greet. Trace d.greet() by hand — which speak runs? Predict before running.

class Animal {
constructor(name) {
  this.name = name;
}
speak() {
  return `${this.name} makes a sound.`;
}
greet() {
  return `Hi, I am ${this.name}. ${this.speak()}`;
}
}

class Dog extends Animal {
speak() {
  return `${this.name} barks.`;
}
}

const d = new Dog('Rex');
console.log(d.greet());

Calling the Parent Method with super

Sometimes you want to extend a parent method rather than fully replace it. Use super.methodName() to call the parent's version and then add your own logic on top.

class Vehicle {
  constructor(make, model) {
    this.make = make;
    this.model = model;
    this.speed = 0;
  }

  accelerate(amount) {
    this.speed += amount;
    return `${this.make} ${this.model} accelerating. Speed: ${this.speed} km/h`;
  }

  describe() {
    return `${this.make} ${this.model}`;
  }
}

class ElectricCar extends Vehicle {
  constructor(make, model, range) {
    super(make, model);
    this.range = range;
    this.battery = 100;
  }

  accelerate(amount) {
    // Extend the parent behaviour
    const result = super.accelerate(amount);
    this.battery -= amount * 0.5;
    return `${result} | Battery: ${this.battery.toFixed(1)}%`;
  }

  describe() {
    // Re-use the parent and add extra info
    return `${super.describe()} (Electric, ${this.range} km range)`;
  }
}

const tesla = new ElectricCar("Tesla", "Model 3", 500);
console.log(tesla.describe());
console.log(tesla.accelerate(10));
console.log(tesla.accelerate(20));

Arrange the code

Reassemble a subclass constructor. Square extends Shape; its constructor takes a side length, passes a fixed sides count of 4 up to the parent, then stores its own sideLength. Order the lines so super runs before this is touched, and the braces close the constructor and then the class.

  1. constructor(sideLength) {
  2. }
  3. super(4);
  4. class Square extends Shape {
  5. this.sideLength = sideLength;
  6. }

Static Methods and Private Fields

Static methods belong to the class itself, not to instances. They are ideal for factory functions, validators, or utility operations related to the class.

Private fields (prefixed with #) are truly inaccessible from outside the class. They enforce encapsulation and prevent accidental mutation.

class BankAccount {
  #balance; // private field

  constructor(owner, initialDeposit) {
    this.owner = owner;
    this.#balance = initialDeposit;
    this.transactions = [];
  }

  deposit(amount) {
    if (amount <= 0) throw new Error("Deposit must be positive");
    this.#balance += amount;
    this.transactions.push({ type: "deposit", amount });
    return this;
  }

  withdraw(amount) {
    if (amount > this.#balance) throw new Error("Insufficient funds");
    this.#balance -= amount;
    this.transactions.push({ type: "withdrawal", amount });
    return this;
  }

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

  summary() {
    return `${this.owner}: £${this.#balance.toFixed(2)} (${this.transactions.length} transactions)`;
  }

  // Static factory method — creates an account with a zero balance
  static empty(owner) {
    return new BankAccount(owner, 0);
  }
}

const account = new BankAccount("Alice", 1000);
account.deposit(500).withdraw(200); // methods return this for chaining
console.log(account.summary());
console.log("Balance:", account.balance);

const newAccount = BankAccount.empty("Bob");
console.log(newAccount.summary());

// A private field is inaccessible from outside the class. Uncommenting the next
// line does NOT throw a catchable error — it is a SyntaxError that stops the whole
// program from running, because #balance can only be referenced inside BankAccount:
// console.log(account.#balance);

The get balance() syntax defines a getter — a property that computes its value from a method. Callers read it like a property (account.balance) but the class controls what they see.

Debug

ResettableCounter extends Counter and tries to zero the parent's #count directly. This should increment to 2, reset, then print 0 — but it does not even run. Predict what happens, then fix it so it prints 2 then 0.

class Counter {
#count = 0;

increment() {
  this.#count++;
  return this;
}

get value() {
  return this.#count;
}
}

class ResettableCounter extends Counter {
reset() {
  // Try to zero the parent's private field from the subclass
  this.#count = 0;
  return this;
}
}

const c = new ResettableCounter();
c.increment().increment();
console.log(c.value);
c.reset();
console.log(c.value);

Expected output: 2 0

Try It Yourself

This is a build task: a small program that reports its own pass/fail. Unlike earlier build tasks, the starter gives you only the two class shells and their names — the design is yours. Read the spec once, then build both classes until it prints All checks passed.

The spec. Build a payroll model with two classes.

  • Employee (the base class). Its constructor takes (name, baseSalary) and stores both. It has a private field #raises that starts at 0. A giveRaise(amount) method adds amount to #raises and returns this so calls can chain. A pay getter returns baseSalary + #raises. A describe() method returns the string `<name> earns <pay>` (reading its own pay getter). Finally a static method fromRecord(record) acts as a factory: if record.role is "manager" it returns new Manager(record.name, record.baseSalary, record.reports), otherwise it returns a plain new Employee(record.name, record.baseSalary).
  • Manager extends Employee. Its constructor takes (name, baseSalary, reports), calls super with the first two, and stores reports. It overrides describe() to extend — not replace — the parent: it returns `<parent describe result>, managing <reports>`, calling super.describe() for the first half.

The checks run top to bottom: Employee (TODO 1) is verified first, then Manager (TODO 2). Run it as-is and the first failure names what Employee is still missing — build that, then work down to Manager.

Build

Finish the build. Two class shells are stubbed out and the checks below them fail until each class is complete. Run it as-is to see which check fails first, decide what that class is missing, then build both until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 (Employee) — build it first, then work down to TODO 2 (Manager).

const assert = require('assert');

// Two employee records from an HR feed. Do NOT change this array.
const records = [
{ role: "staff", name: "Ada", baseSalary: 60000 },
{ role: "manager", name: "Grace", baseSalary: 90000, reports: 4 },
];

// TODO 1: build the Employee base class (see the spec above).
class Employee {
// your code here
}

// TODO 2: build the Manager subclass (see the spec above).
class Manager {
// your code here
}

// --- Build checks: these must all pass. Do not edit below this line. ---
// TODO 1 — Employee
assert.strictEqual(typeof Employee.prototype.giveRaise, "function", "TODO 1: Employee needs a giveRaise method");
const staff = new Employee("Ada", 60000);
staff.giveRaise(5000).giveRaise(2000);
assert.strictEqual(staff.pay, 67000, "TODO 1: pay getter should add all raises to baseSalary");
assert.strictEqual(
staff.describe(),
"Ada earns 67000",
"TODO 1: Employee.describe() should read the pay getter",
);
const plain = Employee.fromRecord(records[0]);
assert.strictEqual(plain.describe(), "Ada earns 60000", "TODO 1: fromRecord on a staff record should build a plain Employee");

// TODO 2 — Manager
assert.strictEqual(Manager.prototype instanceof Employee, true, "TODO 2: Manager must extend Employee");
const boss = Employee.fromRecord(records[1]);
assert.strictEqual(boss instanceof Manager, true, "TODO 2: fromRecord should build a Manager for a manager record");
assert.strictEqual(plain instanceof Manager, false, "TODO 2: a staff record must NOT be a Manager");
assert.strictEqual(
boss.describe(),
"Grace earns 90000, managing 4",
"TODO 2: Manager.describe() should extend the parent describe via super, then add reports",
);

console.log("All checks passed.");
console.log("Staff:", staff.describe());
console.log("Boss:", boss.describe());
console.log("Is boss a Manager?", boss instanceof Manager);

Expected output: All checks passed. Staff: Ada earns 67000 Boss: Grace earns 90000, managing 4 Is boss a Manager? true

Once it passes, try two variations and predict each before running:

  1. Drop the chaining return. In Employee.giveRaise, delete the return this; line so the method returns undefined. Predict what staff.giveRaise(5000).giveRaise(2000) does before running. The first call now returns undefined, so the second .giveRaise is read off undefined and throws TypeError: Cannot read properties of undefined (reading 'giveRaise') — the exact reason methods that chain must return this.
  2. Override without extending. In Manager.describe, return a bare super.describe() and drop the , managing <reports> suffix. Predict what boss.describe() returns before running. You get "Grace earns 90000" — the parent's result verbatim, with the manager-specific part gone — so the check fails. super.describe() hands you the parent's half; extending means you still have to add your own.

Recall

Without scrolling up: you make TWO instances of the SAME class — const rex = new Dog('Rex'); const fido = new Dog('Fido'); — then check rex.speak === fido.speak. It comes back true. Given what you learned about prototypes in 06-objects-and-prototypes, WHERE does that method live, and why is the check true rather than false?

Key Takeaways

  • A class is a blueprint for creating objects. The constructor sets up initial state; methods are shared across all instances via the prototype.
  • Use extends to create a child class that inherits all of a parent's methods. Call super(...) in the child constructor before accessing this.
  • Override parent methods in child classes to specialise behaviour, and call super.methodName() when you want to extend rather than replace the parent's logic.
  • Static methods live on the class itself and are useful for factory functions and utilities that do not depend on instance state.
  • Private fields (#field) are only accessible inside the class body, making encapsulation explicit and enforced by the language.
  • instanceof traverses the full inheritance chain, so a child instance is also an instance of every ancestor class.

Pro Tip: Prefer composition over inheritance for complex domains. Deep inheritance hierarchies become brittle when requirements change. If you find yourself writing more than two levels deep (Animal → Mammal → Dog → GoldenRetriever), consider whether a flat class with injected behaviour — or a set of simple functions — would be easier to maintain.

Next Steps

Now that you can structure code with classes, the next lesson introduces regular expressions — a powerful tool for searching, validating, and transforming strings using pattern matching.

Next lesson

Regular Expressions

Master regular expressions in JavaScript. Learn regex syntax, search and replace patterns, and practical validation techniques.

25 min