Skip to content
elephantoo

Prototypes, classes & inheritance

Lesson 18 of 34 20 min read

The prototype chain, class syntax, constructors, getters/setters, static and #private members, extends and super.


Most programs model things: users, products, bank accounts, game characters. JavaScript lets many objects share behaviour through prototypes, and the class syntax gives you a clean, familiar way to write them. In this lesson you'll learn both — classes are built on prototypes, so understanding one explains the other.

Prototypes: objects inheriting from objects#

Every object has a hidden link to another object called its prototype. When you read a property that the object doesn't have, JavaScript looks at the prototype, then its prototype, and so on. This is the prototype chain.

JavaScript
const animal = {
  eats: true,
  describe() {
    return `${this.name} eats: ${this.eats}`;
  },
};

const rabbit = Object.create(animal); // rabbit's prototype is animal
rabbit.name = "Bugs";

console.log(rabbit.eats);         // true – found on the prototype
console.log(rabbit.describe());   // Bugs eats: true
console.log(Object.hasOwn(rabbit, "eats")); // false – not its own property
console.log(Object.getPrototypeOf(rabbit) === animal); // true

You already use prototypes all the time. Array methods live on Array.prototype:

JavaScript
const nums = [1, 2, 3];
console.log(Object.getPrototypeOf(nums) === Array.prototype);            // true
console.log(Object.getPrototypeOf(Array.prototype) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype));                     // null – end of the chain

So the chain for an array is nums → Array.prototype → Object.prototype → null. That's why nums.map and nums.hasOwnProperty both work.

Never add methods to built-in prototypes like Array.prototype in your own code. It can clash with future language features and other libraries.

Classes#

The class keyword is a cleaner syntax for creating objects that share methods through a prototype:

JavaScript
class BankAccount {
  constructor(owner, balance = 0) {
    this.owner = owner;
    this.balance = balance;
  }

  deposit(amount) {
    if (amount <= 0) throw new RangeError("Deposit must be positive");
    this.balance += amount;
    return this;
  }

  withdraw(amount) {
    if (amount > this.balance) throw new Error("Insufficient funds");
    this.balance -= amount;
    return this;
  }

  toString() {
    return `${this.owner}: ₹${this.balance}`;
  }
}

const acc = new BankAccount("Ada", 1000);
acc.deposit(500).withdraw(200); // returning `this` allows chaining
console.log(String(acc));       // Ada: ₹1300
console.log(acc instanceof BankAccount); // true
console.log(typeof BankAccount);         // "function"
  • constructor runs when you call new BankAccount(...) and sets up the instance.
  • Methods are stored once on BankAccount.prototype and shared by every instance.
  • Classes must be called with new, always run in strict mode, and aren't hoisted like functions.
JavaScript
const a = new BankAccount("A");
const b = new BankAccount("B");
console.log(a.deposit === b.deposit); // true – one shared method

Class fields#

Declare instance properties directly in the class body:

JavaScript
class Counter {
  count = 0;          // public field, set for every instance
  step;               // declared, initially undefined

  constructor(step = 1) {
    this.step = step;
  }

  increment() {
    this.count += this.step;
    return this.count;
  }
}

const byTwo = new Counter(2);
byTwo.increment();
console.log(byTwo.increment()); // 4

Private fields and methods#

Prefix a name with # to make it truly private — enforced by the language:

JavaScript
class Wallet {
  #balance = 0;
  #history = [];

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

  #validate(amount) {
    if (!Number.isFinite(amount) || amount <= 0) {
      throw new RangeError(`Invalid amount: ${amount}`);
    }
  }

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

  get history() {
    return [...this.#history]; // return a copy, not the internal array
  }
}

const w = new Wallet();
w.deposit(100);
w.deposit(50);
console.log(w.balance);  // 150
console.log(w.history);  // [ '+100', '+50' ]
console.log(Object.keys(w)); // [] – private fields are invisible
// w.#balance;           // SyntaxError: Private field '#balance' must be declared in an enclosing class

Before #, developers used an underscore (this._balance) as a polite "please don't touch" — but anything could still access it.

Getters and setters#

JavaScript
class Temperature {
  #celsius;

  constructor(celsius) {
    this.celsius = celsius; // goes through the setter
  }

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

  set celsius(value) {
    if (value < -273.15) throw new RangeError("Below absolute zero!");
    this.#celsius = value;
  }

  get fahrenheit() {
    return this.#celsius * 9 / 5 + 32;
  }
}

const t = new Temperature(25);
console.log(t.fahrenheit); // 77
t.celsius = 100;
console.log(t.fahrenheit); // 212
try {
  t.celsius = -300;
} catch (err) {
  console.log(err.message); // Below absolute zero!
}

Getters are read like properties (t.fahrenheit, no parentheses) — great for derived values and validation.

Static members#

static members belong to the class itself, not to instances. Use them for factory functions, constants and utilities:

JavaScript
class User {
  static #nextId = 1;
  static ROLES = ["student", "teacher", "admin"];

  constructor(name, role = "student") {
    if (!User.ROLES.includes(role)) throw new Error(`Unknown role ${role}`);
    this.id = User.#nextId++;
    this.name = name;
    this.role = role;
  }

  static fromJSON(json) {
    const { name, role } = JSON.parse(json);
    return new User(name, role);
  }
}

const u1 = new User("Ada");
const u2 = User.fromJSON('{"name":"Alan","role":"teacher"}');
console.log(u1.id, u2.id, u2.role); // 1 2 teacher
console.log(User.ROLES.length);     // 3
console.log(u1.ROLES);              // undefined – not on instances

Inheritance with extends and super#

A subclass inherits everything from its parent and can add or override behaviour:

JavaScript
class Shape {
  constructor(name) {
    this.name = name;
  }
  area() {
    return 0;
  }
  describe() {
    return `${this.name} with area ${this.area().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super("Circle"); // must call super() before using `this`
    this.radius = radius;
  }
  area() {
    return Math.PI * this.radius ** 2; // override
  }
}

class Square extends Shape {
  constructor(side) {
    super("Square");
    this.side = side;
  }
  area() {
    return this.side ** 2;
  }
  describe() {
    return `${super.describe()} (side ${this.side})`; // extend the parent's method
  }
}

const shapes = [new Circle(1), new Square(3)];
for (const s of shapes) console.log(s.describe());

console.log(shapes[0] instanceof Circle, shapes[0] instanceof Shape); // true true
Output
Circle with area 3.14
Square with area 9.00 (side 3)
true true

describe() in Shape calls this.area(), and each subclass supplies its own version. This is polymorphism: the same call does the right thing for each type.

Extending built-ins

You can extend built-in classes like Error (see the next lesson) or Array:

JavaScript
class Stack extends Array {
  peek() {
    return this.at(-1);
  }
}
const s = new Stack();
s.push(1, 2, 3);
console.log(s.peek(), s.length); // 3 3

Under the hood: classes are prototypes#

A class is a function, and its methods live on its prototype:

JavaScript
class Dog {
  constructor(name) {
    this.name = name;
  }
  bark() {
    return `${this.name}: woof`;
  }
}

const rex = new Dog("Rex");
console.log(Object.getPrototypeOf(rex) === Dog.prototype); // true
console.log(Object.hasOwn(rex, "bark"));                   // false – it's on the prototype
console.log(Object.hasOwn(Dog.prototype, "bark"));         // true

Before ES2015 you'd write the same thing with a constructor function and assign Dog.prototype.bark = function () {...}. You'll still see that style in older code.

Composition over inheritance#

Deep inheritance trees become rigid: a FlyingSwimmingRobotDog doesn't fit anywhere. Often it's better to compose small pieces:

JavaScript
const canFly = (obj) => ({ ...obj, fly: () => `${obj.name} is flying` });
const canSwim = (obj) => ({ ...obj, swim: () => `${obj.name} is swimming` });

const duck = canSwim(canFly({ name: "Duck" }));
console.log(duck.fly());  // Duck is flying
console.log(duck.swim()); // Duck is swimming

A good rule: use inheritance for a genuine "is-a" relationship one or two levels deep (a Circle is a Shape), and composition for "has-a" or "can-do" capabilities. Modern front-end code (like React) uses very little inheritance.

Common mistakes#

  • Forgetting new: BankAccount("Ada") throws Class constructor cannot be invoked without 'new'.
  • Using this before super() in a subclass constructor.
  • Passing a method as a callback and losing this (see the previous lesson).
  • Putting methods inside the constructor (this.deposit = function…), creating a new copy for every instance.
  • Returning internal arrays/objects from getters, letting callers mutate private state.

What's next#

You've already seen throw and RangeError here. Next, learn error handling properly: try/catch/finally, custom error classes and defensive code.

Check your understanding

Quick quiz

0/3 answered
  1. 1.When you read obj.prop and obj has no own property prop, what does JavaScript do?

  2. 2.In a subclass constructor, what must you do before using this?

  3. 3.What does a #balance field in a class give you?

Finished reading?

Mark this lesson complete to track your progress.