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 callsuperbefore you usethisin a child constructor.Dogoverridesspeakwith its own version. The parent's version is no longer called unless you explicitly invoke it.instanceofchecks the full inheritance chain, so aDogis also anAnimal.
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));
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?
this.sideLength = sideLength;constructor(sideLength) {class Square extends Shape {super(4);} }
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#raisesthat starts at0. AgiveRaise(amount)method addsamountto#raisesand returnsthisso calls can chain. Apaygetter returnsbaseSalary + #raises. Adescribe()method returns the string`<name> earns <pay>`(reading its ownpaygetter). Finally a static methodfromRecord(record)acts as a factory: ifrecord.roleis"manager"it returnsnew Manager(record.name, record.baseSalary, record.reports), otherwise it returns a plainnew Employee(record.name, record.baseSalary).ManagerextendsEmployee. Its constructor takes(name, baseSalary, reports), callssuperwith the first two, and storesreports. It overridesdescribe()to extend — not replace — the parent: it returns`<parent describe result>, managing <reports>`, callingsuper.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:
- Drop the chaining return. In
Employee.giveRaise, delete thereturn this;line so the method returnsundefined. Write down whatstaff.giveRaise(5000).giveRaise(2000)does before running — a value, or an error, and if an error then which one. - Override without extending. In
Manager.describe, return a baresuper.describe()and drop the, managing <reports>suffix. Write down the exact stringboss.describe()returns before running.
After you have written your prediction down, check it against what actually happens.
What you should have seen
- It throws
TypeError: Cannot read properties of undefined (reading 'giveRaise'). The first call now returnsundefined, and the second.giveRaiseis read off thatundefined. This is the exact reason methods that chain mustreturn this— the failure lands on the second call, one step away from the line you changed. - 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?
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;
}
}Key Takeaways
- A class is a blueprint for creating objects. The
constructorsets up initial state; methods are shared across all instances via the prototype. - Use
extendsto create a child class that inherits all of a parent's methods. Callsuper(...)in the child constructor before accessingthis. - 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. instanceoftraverses 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