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.
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));
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.
constructor(sideLength) {}super(4);class Square extends Shape {this.sideLength = sideLength;}
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. Predict whatstaff.giveRaise(5000).giveRaise(2000)does before running. The first call now returnsundefined, so the second.giveRaiseis read offundefinedand throwsTypeError: Cannot read properties of undefined (reading 'giveRaise')— the exact reason methods that chain mustreturn this. - Override without extending. In
Manager.describe, return a baresuper.describe()and drop the, managing <reports>suffix. Predict whatboss.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
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.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.