JavaScript classes and OOP complete guide 2026

JavaScript Classes and Prototypes: Complete OOP Guide

JavaScript classes and prototypes form the backbone of object-oriented programming in the language. Unlike Java or C++ which use classical inheritance, JavaScript uses prototypal inheritance where objects inherit directly from other objects. The class keyword introduced in ES6 is syntactic sugar over this prototype system. Understanding both prototypes and classes is critical for writing maintainable JavaScript, working with frameworks like React, and acing technical interviews. This tutorial takes you from prototype fundamentals to advanced class patterns.

Prototypes: The Foundation of JavaScript OOP

Every JavaScript object has an internal link to another object called its prototype. When you access a property that doesn’t exist on an object, JavaScript looks up the prototype chain until it finds the property or reaches null.

const animal = {
  type: 'Animal',
  speak() {
    return `${this.name} makes a sound.`;
  }
};

const dog = Object.create(animal);
dog.name = 'Rex';

console.log(dog.speak());      // "Rex makes a sound."
console.log(dog.type);          // "Animal" (inherited)
console.log(dog.hasOwnProperty('name'));  // true
console.log(dog.hasOwnProperty('type'));  // false (inherited)

Object.create(animal) creates a new object whose prototype is animal. The dog object doesn’t have a speak method or type property of its own — JavaScript finds them by walking up the prototype chain. Understanding how objects work is prerequisite knowledge for mastering prototypes.

The Prototype Chain Explained

The prototype chain is a linked list of objects. Every lookup traverses this chain from the target object upward to Object.prototype and finally to null.

const grandparent = { family: 'Smith', greet() { return 'Hello!'; } };
const parent = Object.create(grandparent);
parent.role = 'parent';

const child = Object.create(parent);
child.name = 'Alex';

// Property lookup chain:
// child.name       -> found on child: "Alex"
// child.role       -> not on child -> found on parent: "parent"
// child.family     -> not on child -> not on parent -> found on grandparent: "Smith"
// child.greet()    -> walks up to grandparent
// child.toString() -> walks up to Object.prototype

console.log(Object.getPrototypeOf(child) === parent);       // true
console.log(Object.getPrototypeOf(parent) === grandparent); // true
console.log(Object.getPrototypeOf(grandparent) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype));       // null

Constructor Functions (Pre-ES6)

Before classes, JavaScript used constructor functions with new:

function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.greet = function() {
  return `Hi, I'm ${this.name}, age ${this.age}`;
};

const alice = new Person('Alice', 30);
console.log(alice.greet()); // "Hi, I'm Alice, age 30"

// What 'new' actually does:
// 1. Creates empty object: {}
// 2. Sets its prototype: Object.setPrototypeOf({}, Person.prototype)
// 3. Calls Person with 'this' bound to the new object
// 4. Returns the object (unless constructor returns an object)

The this keyword inside a constructor refers to the newly created instance. Understanding this binding is essential for working with both constructors and classes.

ES6 Class Syntax

Classes provide a cleaner syntax for creating constructor functions and setting up the prototype chain:

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet() {
    return `Hi, I'm ${this.name}, age ${this.age}`;
  }

  toString() {
    return `Person(${this.name})`;
  }
}

const bob = new Person('Bob', 25);
console.log(bob.greet());            // "Hi, I'm Bob, age 25"
console.log(bob instanceof Person);  // true
console.log(typeof Person);          // "function" — classes ARE functions!

Classes are not hoisted like function declarations. You must define a class before using it. Classes also run in strict mode automatically.

Constructors and Instance Properties

class User {
  // Class fields (modern syntax, ES2022)
  role = 'user';
  loginCount = 0;
  #password;  // Private field (see next section)

  constructor(name, email, password) {
    this.name = name;
    this.email = email;
    this.#password = password;
    this.createdAt = new Date();
  }
}

const user = new User('Alice', 'alice@example.com', 'secret123');
console.log(user.role);       // "user" (set by class field)
console.log(user.loginCount); // 0
console.log(user.name);       // "Alice" (set in constructor)

Class fields declared outside the constructor are initialized before the constructor runs. They create own properties on each instance (not on the prototype).

Methods, Static Methods, and Private Fields

Instance Methods

Methods defined in the class body are placed on the prototype and shared across all instances:

class Calculator {
  constructor(value = 0) {
    this.value = value;
  }

  add(n) { this.value += n; return this; }
  subtract(n) { this.value -= n; return this; }
  multiply(n) { this.value *= n; return this; }
  result() { return this.value; }
}

// Method chaining
const answer = new Calculator(10)
  .add(5)
  .multiply(2)
  .subtract(3)
  .result(); // 27

Static Methods

Static methods belong to the class itself, not instances:

class MathUtils {
  static clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
  }

  static lerp(start, end, t) {
    return start + (end - start) * t;
  }

  static random(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }
}

console.log(MathUtils.clamp(15, 0, 10));  // 10
console.log(MathUtils.lerp(0, 100, 0.5)); // 50
// MathUtils is never instantiated

Private Fields and Methods (ES2022)

class BankAccount {
  #balance = 0;
  #transactionLog = [];

  constructor(owner, initialBalance) {
    this.owner = owner;
    this.#balance = initialBalance;
    this.#log('Account created');
  }

  #log(action) {
    this.#transactionLog.push({
      action,
      balance: this.#balance,
      timestamp: Date.now()
    });
  }

  deposit(amount) {
    if (amount <= 0) throw new Error('Invalid amount');
    this.#balance += amount;
    this.#log(`Deposit: ${amount}`);
    return this.#balance;
  }

  withdraw(amount) {
    if (amount > this.#balance) throw new Error('Insufficient funds');
    this.#balance -= amount;
    this.#log(`Withdrawal: ${amount}`);
    return this.#balance;
  }

  get balance() { return this.#balance; }
  get history() { return [...this.#transactionLog]; }
}

const account = new BankAccount('Alice', 1000);
account.deposit(500);
console.log(account.balance);    // 1500
// account.#balance;             // SyntaxError: Private field

Class Inheritance with extends

The extends keyword creates a subclass that inherits methods and properties from a parent class:

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

  describe() {
    return `A ${this.color} ${this.constructor.name}`;
  }
}

class Circle extends Shape {
  constructor(color, radius) {
    super(color); // Must call super() before using 'this'
    this.radius = radius;
  }

  area() {
    return Math.PI * this.radius ** 2;
  }

  circumference() {
    return 2 * Math.PI * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(color, width, height) {
    super(color);
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }

  perimeter() {
    return 2 * (this.width + this.height);
  }
}

const circle = new Circle('red', 5);
console.log(circle.describe());    // "A red Circle"
console.log(circle.area());        // 78.539...
console.log(circle instanceof Circle); // true
console.log(circle instanceof Shape);  // true

The super Keyword

super serves two purposes in classes:

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

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

class Dog extends Animal {
  constructor(name, breed) {
    super(name);          // 1. Call parent constructor
    this.breed = breed;
  }

  speak() {
    const parentMsg = super.speak(); // 2. Call parent method
    return `${parentMsg} Actually, ${this.name} barks!`;
  }
}

const dog = new Dog('Rex', 'Labrador');
console.log(dog.speak());
// "Rex makes a generic sound. Actually, Rex barks!"

Important rules:

  • You must call super() in a derived constructor before accessing this
  • If a derived class has no constructor, an implicit one calls super(...args)
  • super.method() calls the parent’s version even if overridden

Mixins and Composition

JavaScript only supports single inheritance. Mixins provide a way to share behavior across unrelated classes:

// Mixin factory functions
const Serializable = (Base) => class extends Base {
  toJSON() {
    const obj = {};
    for (const key of Object.keys(this)) {
      obj[key] = this[key];
    }
    return JSON.stringify(obj);
  }

  static fromJSON(json) {
    return Object.assign(new this(), JSON.parse(json));
  }
};

const Validatable = (Base) => class extends Base {
  validate() {
    for (const [key, value] of Object.entries(this)) {
      if (value === null || value === undefined) {
        throw new Error(`Validation failed: ${key} is required`);
      }
    }
    return true;
  }
};

// Compose mixins
class User extends Serializable(Validatable(class {})) {
  constructor(name, email) {
    super();
    this.name = name;
    this.email = email;
  }
}

const user = new User('Alice', 'alice@example.com');
user.validate();                    // true
console.log(user.toJSON());        // '{"name":"Alice","email":"alice@example.com"}'

This pattern uses closures and higher-order functions to create composable class extensions.

Getters, Setters, and Computed Properties

class Temperature {
  #celsius;

  constructor(celsius) {
    this.#celsius = celsius;
  }

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

  set fahrenheit(f) {
    this.#celsius = (f - 32) * 5/9;
  }

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

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

  toString() {
    return `${this.#celsius.toFixed(1)}°C (${this.fahrenheit.toFixed(1)}°F)`;
  }
}

const temp = new Temperature(100);
console.log(temp.fahrenheit);   // 212
temp.fahrenheit = 32;
console.log(temp.celsius);     // 0
console.log(temp.toString());  // "0.0°C (32.0°F)"

Classes Under the Hood: Still Prototypes

Classes are syntactic sugar. Everything a class does can be replicated with prototypes:

// This class:
class Person {
  constructor(name) { this.name = name; }
  greet() { return `Hi, I'm ${this.name}`; }
  static create(name) { return new Person(name); }
}

// Is equivalent to:
function PersonOld(name) { this.name = name; }
PersonOld.prototype.greet = function() { return `Hi, I'm ${this.name}`; };
PersonOld.create = function(name) { return new PersonOld(name); };

// Proof:
console.log(typeof Person); // "function"
console.log(Person.prototype.constructor === Person); // true
console.log(Object.getPrototypeOf(new Person('A')) === Person.prototype); // true

Inheritance with extends sets up the prototype chain:

class Animal { }
class Dog extends Animal { }

console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(Object.getPrototypeOf(Dog) === Animal); // true (static inheritance)

Common Mistakes and Pitfalls

1. Losing this in Callbacks

class Timer {
  count = 0;

  start() {
    // WRONG: 'this' is lost in setTimeout callback
    setInterval(function() {
      this.count++; // TypeError: Cannot read property 'count'
    }, 1000);

    // FIX 1: Arrow function (inherits this)
    setInterval(() => { this.count++; }, 1000);

    // FIX 2: Bind
    setInterval(function() { this.count++; }.bind(this), 1000);
  }
}

2. Forgetting super() in Derived Constructors

class Child extends Parent {
  constructor() {
    // this.x = 1; // ReferenceError! Must call super() first
    super();
    this.x = 1;    // Now it's safe
  }
}

3. Confusing prototype and __proto__

function Foo() {}
const foo = new Foo();

// foo.__proto__ === Foo.prototype  (the instance's prototype link)
// Foo.prototype !== Foo.__proto__  (these are different!)
// Foo.__proto__ === Function.prototype

4. Overriding Without Calling super.method()

class Base {
  init() {
    this.initialized = true;
  }
}

class Derived extends Base {
  init() {
    // Forgot to call super.init()!
    this.extra = true;
  }
}

const d = new Derived();
d.init();
console.log(d.initialized); // undefined! Base.init() never ran

5. Using Arrays or Objects as Default Class Fields

class BadList {
  items = []; // This is fine! Each instance gets its own array.
  // But on the PROTOTYPE it would be shared:
}
// Unlike Python, JS class fields create own properties per instance.
// This is actually correct behavior, but developers from other languages get confused.

Summary and Key Takeaways

  • Prototypes are JavaScript’s native inheritance mechanism — objects inherit from other objects via a prototype chain
  • The class keyword is syntactic sugar over prototype-based patterns
  • Use extends for inheritance and super to call parent constructors/methods
  • Private fields (#field) provide true encapsulation since ES2022
  • Static methods belong to the class, not instances — use for utility functions and factory methods
  • Getters and setters create computed properties with validation
  • Mixins solve the single-inheritance limitation through composition
  • Always use arrow functions or .bind() when passing methods as callbacks to preserve this
  • Understanding prototypes deeply will help you debug instanceof, property lookup, and inheritance issues
  • Classes are the standard for modern JavaScript — MDN’s class reference is essential reading

With classes and prototypes mastered, you are ready to tackle more advanced patterns. Continue to the next lesson on Iterators and Generators to learn how to create custom iteration protocols and lazy sequences.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *