JavaScript OOP Principles: Objects, Classes, and Inheritance

Object-oriented programming in JavaScript helps you model real-world things as objects with data and behavior. This article explains the main OOP principles in JavaScript, how classes and prototypes work together, and how to write cleaner, more maintainable code with ES2020+ syntax.

Quick answer: JavaScript supports object-oriented programming through objects, prototypes, constructor functions, and class syntax. The class keyword is mostly a clearer way to work with prototype-based inheritance, not a separate object system.

Difficulty: Beginner

You'll understand this better if you know: basic JavaScript syntax, how functions work, and how objects store properties and methods.

1. What Is OOP in JavaScript?

Object-oriented programming, or OOP, is a way of organizing code around objects. In JavaScript, an object can store data in properties and behavior in methods. OOP is useful when you want to model things like users, products, carts, accounts, or game characters.

For beginners, the easiest way to think about OOP in JavaScript is: group related values and functions together so your code is easier to understand and reuse.

2. Why OOP in JavaScript Matters

As programs grow, plain functions and loose data can become hard to manage. OOP helps you keep related logic together, which makes code easier to extend, test, and refactor.

It matters most when you have many similar items that share behavior. For example, a shopping app might need many product objects, each with its own price and name, but all with the same methods for discounting or formatting.

OOP is not always the best choice. For small scripts or simple data transformations, plain functions and data objects may be simpler. But when you need reusable models with shared behavior, OOP can be a strong fit.

3. Basic Syntax or Core Idea

JavaScript can create objects directly, but classes give a structured way to define them. A class usually has a constructor for initial values and methods for behavior.

Minimal class example

This example shows the smallest practical class-based object pattern in modern JavaScript.

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

  greet() {
    return `Hello, ${this.name}!`;
  }
}

const user = new User("Ava");
console.log(user.greet());

Here, User is the blueprint, constructor sets initial state, this.name stores data on the instance, and greet() defines behavior.

4. Step-by-Step Examples

Example 1: Creating an object with methods

A plain object is the simplest OOP-style structure in JavaScript. It is useful when you only need one object or a few related values.

const book = {
  title: "Clean Code",
  author: "Robert C. Martin",
  summary() {
    return `${this.title} by ${this.author}`;
  }
};

console.log(book.summary());

This object keeps data and behavior together. It is simple, direct, and often enough for one-off data structures.

Example 2: Using a constructor function

Constructor functions are an older but still important JavaScript pattern. They show how OOP worked before the class syntax became common.

function Car(make, model) {
  this.make = make;
  this.model = model;
}

Car.prototype.describe = function() {
  return `${this.make} ${this.model}`;
};

const car = new Car("Toyota", "Corolla");
console.log(car.describe());

The constructor function sets up the instance, and the method is stored on the prototype so all instances share it.

Example 3: Class inheritance with extends

Inheritance lets one class reuse and extend another class. This is useful when several types share common behavior.

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

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

class Dog extends Animal {
  speak() {
    return `${this.name} barks.`;
  }
}

const dog = new Dog("Milo");
console.log(dog.speak());

Dog inherits from Animal and replaces the speak behavior with its own version.

Example 4: Encapsulation with getters and setters

Encapsulation means controlling how data is accessed and changed. JavaScript supports this with getters, setters, and private class fields.

class BankAccount {
  #balance = 0;

  constructor(owner) {
    this.owner = owner;
  }

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

  deposit(amount) {
    if (amount > 0) {
      this.#balance += amount;
    }
  }
}

const account = new BankAccount("Nora");
account.deposit(100);
console.log(account.balance);

The private field #balance cannot be accessed directly from outside the class, which protects the account state.

5. Practical Use Cases

OOP is especially useful when an object needs both data and methods that act on that data over time.

6. Common Mistakes

Mistake 1: Forgetting to use new with a constructor

Constructor functions and class constructors must be called with new. Without it, this is not bound to a fresh instance, so the code fails or behaves incorrectly.

Problem: This often leads to a runtime error such as Class constructor User cannot be invoked without 'new' or creates broken state in older patterns.

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

const user = User("Sam");

Fix: Create the instance with new so JavaScript allocates an object and binds this correctly.

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

const user = new User("Sam");

This works because new tells JavaScript to build an instance from the class.

Mistake 2: Using an instance method like a detached function

Methods often depend on this. If you pass a method around without binding it, the method can lose its object context.

Problem: In strict mode, this may become undefined, causing errors like Cannot read properties of undefined.

class Counter {
  constructor() {
    this.count = 0;
  }

  increment() {
    this.count++;
  }
}

const counter = new Counter();
const tick = counter.increment;
tick();

Fix: Bind the method, wrap the call, or use an arrow function when appropriate.

class Counter {
  constructor() {
    this.count = 0;
    this.increment = this.increment.bind(this);
  }

  increment() {
    this.count++;
  }
}

const counter = new Counter();
const tick = counter.increment;
tick();

The bound method keeps the correct object context, so the instance state updates safely.

Mistake 3: Putting shared methods directly inside the constructor

If you define a method inside the constructor, every instance gets its own copy of that function. That wastes memory and makes shared behavior harder to maintain.

Problem: The code works, but each object stores a separate function instead of sharing one method on the prototype.

class Task {
  constructor(title) {
    this.title = title;
    this.describe = function() {
      return `Task: ${this.title}`;
    };
  }
}

Fix: Put shared methods on the class body so all instances reuse the same function.

class Task {
  constructor(title) {
    this.title = title;
  }

  describe() {
    return `Task: ${this.title}`;
  }
}

This version is better because the method lives on the prototype and is shared by every Task instance.

7. Best Practices

Practice 1: Prefer composition when inheritance gets too deep

Inheritance is useful, but deep class trees become hard to reason about. If a class is mostly reusing behavior from many places, composition is often clearer.

class Logger {
  log(message) {
    console.log(message);
  }
}

class Service {
  constructor(logger) {
    this.logger = logger;
  }
}

Composition keeps responsibilities separate and makes behavior easier to swap.

Practice 2: Keep object state small and focused

Classes work best when each instance has a clear purpose. If a class stores too many unrelated values, it often means the design needs to be split into smaller objects.

class Profile {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

A focused class is easier to test and less likely to accumulate unrelated responsibilities.

Practice 3: Use private fields for internal data

When internal state should not be changed from outside, private fields help protect it and make the API clearer.

class Playlist {
  #songs = [];

  addSong(song) {
    this.#songs.push(song);
  }

  getSongs() {
    return [...this.#songs];
  }
}

Private fields reduce accidental misuse and make the public interface easier to understand.

8. Limitations and Edge Cases

A common surprise is that a class instance does not automatically become a full object record with methods after JSON parsing. If you need methods again, you usually reconstruct the instance manually.

9. Practical Mini Project

Let's build a small expense tracker model. It uses OOP ideas to store expenses, calculate totals, and produce a summary.

class ExpenseTracker {
  constructor() {
    this.expenses = [];
  }

  addExpense(description, amount) {
    if (typeof amount !== "number" || amount <= 0) {
      throw new Error("Amount must be a positive number.");
    }

    this.expenses.push({ description, amount });
  }

  getTotal() {
    return this.expenses.reduce((sum, expense) => sum + expense.amount, 0);
  }

  getSummary() {
    return `You have ${this.expenses.length} expenses totaling ${this.getTotal()}.`;
  }
}

const tracker = new ExpenseTracker();
tracker.addExpense("Coffee", 4.5);
tracker.addExpense("Books", 25);
console.log(tracker.getSummary());

This mini project shows encapsulation through methods, state stored inside the object, and reusable behavior across multiple calls.

10. Key Points

11. Practice Exercise

Expected output: Two descriptive strings, one for a regular vehicle and one for an electric vehicle with its battery level.

Hint: Remember to call super() inside the child class constructor before using this.

class Vehicle {
  constructor(make, model) {
    this.make = make;
    this.model = model;
  }

  describe() {
    return `${this.make} ${this.model}`;
  }
}

class ElectricVehicle extends Vehicle {
  constructor(make, model, batteryLevel) {
    super(make, model);
    this.batteryLevel = batteryLevel;
  }

  describe() {
    return `${super.describe()} with battery at ${this.batteryLevel}%`;
  }
}

const vehicle = new Vehicle("Honda", "Civic");
const electricVehicle = new ElectricVehicle("Tesla", "Model 3", 82);

console.log(vehicle.describe());
console.log(electricVehicle.describe());

12. Final Summary

JavaScript OOP gives you a way to organize code around objects that hold both data and behavior. You can use plain objects for small structures, constructor functions for older patterns, and classes for modern, readable OOP code. Under the hood, JavaScript still uses prototypes, so the class syntax is mostly a cleaner interface for the same model.

When used well, OOP improves structure, reuse, and maintainability. Encapsulation helps protect state, inheritance helps share behavior, and composition helps keep designs flexible. If you are building anything larger than a quick script, understanding JavaScript OOP principles will help you write code that is easier to extend and reason about.

Next, try comparing class-based design with composition in one of your own projects so you can see where each approach feels most natural.