Skip to lesson

learningjavascript.org / intermediate / 18-classes-and-inheritance · lesson 20 of 25

TL;DR

Learn JavaScript classes and inheritance. Build reusable blueprints with constructors, methods, static fields, and extends for OOP.

Key concepts

  • JavaScript classes
  • JavaScript inheritance
  • JS class tutorial
  • JavaScript OOP

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.

What You'll Learn

By the end of this lesson you will build a two-class payroll model: an Employee base class that keeps its raises in a private field and exposes a computed pay, and a Manager subclass that extends — rather than replaces — the parent's describe(). You arrive able to build objects from constructor functions and follow a prototype chain (06-objects-and-prototypes); you leave able to express the same relationships with class, extends and super, plus the two things plain constructor functions cannot say: truly private #fields and static factory methods.

This lesson is part of the optional extension tail. The Task Manager capstone is complete without it — the capstone deliberately stays class-free — so treat this as depth on the object model you already have rather than a missing piece of the final project.

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());
Continue learning

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));

Here is the setting for the block below — the parent class, and the two lines that exercise the subclass once you have assembled it:

class Shape {
	constructor(sides) {
		this.sides = sides;
	}
}

// the five entries of the block below go here, defining class Square

const sq = new Square(5);
console.log(sq.sides, sq.sideLength);
// -> 4 5   (4 came from super(4) via Shape; 5 is Square's own sideLength)

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. The last entry closes the constructor and the class together, in one piece — why can no other arrangement of these five run at all?

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

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
Continue learning

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
Continue learning

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. Write down what staff.giveRaise(5000).giveRaise(2000) does before running — a value, or an error, and if an error then which one.
  2. Override without extending. In Manager.describe, return a bare super.describe() and drop the , managing <reports> suffix. Write down the exact string boss.describe() returns before running.

After you have written your prediction down, check it against what actually happens.

What you should have seen
  1. It throws TypeError: Cannot read properties of undefined (reading 'giveRaise'). The first call now returns undefined, and the second .giveRaise is read off that undefined. This is the exact reason methods that chain must return this — the failure lands on the second call, one step away from the line you changed.
  2. 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?

Continue learning

Transfer

Same invariant, older surface. Before class existed, 06-objects-and-prototypes built the same relationships with a constructor function plus Book.prototype.getSummary = ... The invariant you just learned is that methods live ONCE on the prototype and are shared by every instance. You are porting a Timer class to the pre-class form, and it must keep that invariant: two timers must satisfy t1.tick === t2.tick. Which port holds it?

// The class you are porting
class Timer {
constructor(label) {
  this.label = label;
  this.ticks = 0;
}
tick() {
  this.ticks += 1;
  return this;
}
}
Continue learning

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.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.