Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 010 / 100

Prototypes and classes: a practical guide for developers

How inheritance really works in JavaScript.

Expona Admin8 min read

Prototypes and classes: How inheritance really works in JavaScript

Inheritance in JavaScript often feels mysterious to developers coming from classical languages like Java or C#. The truth is simpler than you might think: prototypes and classes are two ways of expressing the same concept. Understanding the prototype chain—how objects delegate property lookups to their prototypes—is fundamental to writing maintainable code at scale. In this article, we'll cut through the confusion and show you how inheritance actually works, when to use classes versus composition, and how to leverage private fields for real encapsulation.

The prototype chain: Objects delegate property lookups to their prototype

Every object in JavaScript has an internal [[Prototype]] reference (accessible via Object.getPrototypeOf() or the non-standard __proto__). When you try to access a property on an object, JavaScript looks it up in this order:

  1. The object itself
  2. Its prototype
  3. The prototype's prototype (and so on)
  4. Finally, null

This chain is called the prototype chain, and it's how inheritance works in JavaScript.

Let me show you a concrete example. Imagine you're building a user management system:

typescript
const userPrototype = {
  getFullName() {
    return `${this.firstName} ${this.lastName}`;
  },
  isAdmin() {
    return this.role === 'admin';
  }
};

const user1 = Object.create(userPrototype);
user1.firstName = 'Alice';
user1.lastName = 'Chen';
user1.role = 'user';

const user2 = Object.create(userPrototype);
user2.firstName = 'Bob';
user2.lastName = 'Smith';
user2.role = 'admin';

console.log(user1.getFullName()); // "Alice Chen"
console.log(user2.isAdmin()); // true

When you call user1.getFullName(), JavaScript doesn't find getFullNameuserPrototype and finds it there. Both user1 and user2 share the same methods without duplicating them—this is memory efficient and elegant.

You can inspect the prototype chain yourself:

typescript
Object.getPrototypeOf(user1) === userPrototype; // true
Object.getPrototypeOf(userPrototype); // Object.prototype
Object.getPrototypeOf(Object.prototype); // null (the end of the chain)

In real-world applications like the e-commerce platforms I've built, this pattern scales well because you can extend behavior by adding methods to shared prototypes, and all instances automatically gain access to them.

Classes are sugar: class syntax builds the same prototype chain

JavaScript's class syntax (introduced in ES2015) is syntactic sugar over the prototype system. It doesn't create a new inheritance model—it just makes the prototype chain easier to read and write.

Here's the same user example using a class:

typescript
class User {
  firstName: string;
  lastName: string;
  role: 'user' | 'admin';

  constructor(firstName: string, lastName: string, role: 'user' | 'admin') {
    this.firstName = firstName;
    this.lastName = lastName;
    this.role = role;
  }

  getFullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }

  isAdmin(): boolean {
    return this.role === 'admin';
  }
}

const user1 = new User('Alice', 'Chen', 'user');
const user2 = new User('Bob', 'Smith', 'admin');

console.log(user1.getFullName()); // "Alice Chen"
console.log(user2.isAdmin()); // true

Behind the scenes, User.prototype contains getFullName and isAdmin. The new keyword creates a fresh object with [[Prototype]] set to User.prototype. It's the exact same mechanism as before, just with cleaner syntax.

Class inheritance works the same way:

typescript
class User {
  firstName: string;
  lastName: string;

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  getFullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }
}

class AdminUser extends User {
  permissions: string[];

  constructor(firstName: string, lastName: string, permissions: string[]) {
    super(firstName, lastName);
    this.permissions = permissions;
  }

  canDelete(): boolean {
    return this.permissions.includes('delete');
  }
}

const admin = new AdminUser('Charlie', 'Davis', ['read', 'write', 'delete']);
console.log(admin.getFullName()); // "Charlie Davis" (inherited method)
console.log(admin.canDelete()); // true

When you call admin.getFullName(), JavaScript walks the prototype chain: admin → AdminUser.prototype → User.prototype (found!). The extends keyword sets up this chain automatically.

In production systems I've worked on—especially CRM and SaaS platforms—classes make code more maintainable than raw prototype manipulation. They're familiar to most developers and TypeScript provides excellent type safety on top of them.

Private fields: #field gives real privacy at runtime

Before private fields, JavaScript had no way to truly hide data. You could use conventions (like prefixing with _) or closures, but nothing was enforced by the language:

typescript
class BankAccount {
  _balance = 0; // Convention, not enforced

  deposit(amount: number) {
    this._balance += amount;
  }

  getBalance(): number {
    return this._balance;
  }
}

const account = new BankAccount();
account.deposit(100);
account._balance = 10000; // Nothing stops you from doing this
console.log(account.getBalance()); // 10000 (cheating!)

Private fields (using the # syntax) fix this:

typescript
class BankAccount {
  #balance = 0; // Truly private

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

  getBalance(): number {
    return this.#balance;
  }
}

const account = new BankAccount();
account.deposit(100);
// account.#balance = 10000; // ❌ SyntaxError: private field
console.log(account.getBalance()); // 100 (safe)

Private fields are enforced at runtime and at the syntax level—you literally cannot access them from outside the class. In trading platforms and payment systems I've built, this is crucial for preventing accidental (or malicious) manipulation of sensitive state.

You can also use private methods:

typescript
class PaymentProcessor {
  #apiKey: string;

  constructor(apiKey: string) {
    this.#apiKey = apiKey;
  }

  #validateTransaction(amount: number): boolean {
    return amount > 0 && amount < 1000000;
  }

  processPayment(amount: number): boolean {
    if (!this.#validateTransaction(amount)) {
      return false;
    }
    // Use this.#apiKey to call external service
    return true;
  }
}

Private fields and methods are compiled away to use WeakMaps or symbols under the hood, so they have real runtime overhead—use them for data that genuinely needs protection, not for every field.

Prefer composition: Deep class hierarchies are rarely needed in JS

A common mistake I see is creating deep class hierarchies. In classical languages, this is normal. In JavaScript, it usually signals a design problem.

Consider this bad hierarchy:

typescript
class Entity {}
class Being extends Entity {}
class Animal extends Being {}
class Mammal extends Animal {}
class User extends Mammal {} // Way too deep

This is brittle. If you later need a Robot class that has some User-like behavior, you're stuck—it can't inherit from User because a robot isn't a mammal.

Composition is cleaner in JavaScript. Instead of inheritance hierarchies, compose behavior using objects:

typescript
// Reusable behavior as objects
const canAuthenticate = {
  authenticate(password: string): boolean {
    return this.passwordHash === hashPassword(password);
  }
};

const canTradeAssets = {
  buyStock(symbol: string, quantity: number): void {
    // Trading logic
  },
  sellStock(symbol: string, quantity: number): void {
    // Trading logic
  }
};

const canViewAnalytics = {
  getAnalyticsDashboard(): object {
    return { /* dashboard data */ };
  }
};

// Compose a User by mixing behaviors
class User {
  passwordHash: string;

  constructor(email: string, passwordHash: string) {
    this.email = email;
    this.passwordHash = passwordHash;
    Object.assign(this, canAuthenticate, canViewAnalytics);
  }
}

// Compose a Trader differently
class Trader {
  constructor(apiKey: string) {
    this.apiKey = apiKey;
    Object.assign(this, canTradeAssets, canViewAnalytics);
  }
}

const user = new User('alice@example.com', 'hash123');
user.authenticate('password'); // Works (from canAuthenticate)
user.getAnalyticsDashboard(); // Works (from canViewAnalytics)
// user.buyStock(); // Doesn't exist—not needed for users

const trader = new Trader('key123');
trader.buyStock('AAPL', 10); // Works
trader.getAnalyticsDashboard(); // Works
// trader.authenticate(); // Doesn't exist—not needed for traders

This approach—mixing behaviors into objects—is more flexible. You can share behavior across unrelated classes, and it's easier to test and reason about.

In ride-hailing platforms I've worked on, composition was essential: drivers, passengers, and support staff all shared different combinations of features. A deep class hierarchy would have been a nightmare.

Common mistakes

  • Forgetting that methods are shared on the prototype: If you mutate a shared method, it affects all instances. Usually not a problem, but watch out with nested objects or arrays on the prototype.
  • Building class hierarchies deeper than 2-3 levels: It gets hard to understand and maintain. Consider composition instead.
  • Using private fields for all fields: Private fields have a small runtime cost. Use them for data that needs protection; public fields for everything else.
  • Relying on prototype pollution for monkey-patching: Modifying built-in prototypes (like Array.prototype) is a code smell. It breaks encapsulation and causes bugs in larger teams.
  • Assuming class inheritance is identical to Java or C# inheritance: It's similar but not the same. JavaScript has no static type system enforcing contracts, so less rigorous design often works better.

Key takeaways

  • JavaScript inheritance is built on the prototype chain—property lookups delegate up the chain until found or null is reached.
  • Classes are syntactic sugar over prototypes. They build the same prototype chain but with cleaner, more familiar syntax.
  • Private fields with # enforce real privacy at runtime, not just convention. Use them for sensitive data.
  • Composition is preferred over deep inheritance hierarchies in JavaScript. Mix behaviors into objects rather than stacking classes.
  • Most real-world systems benefit from flat class structures (0-1 level of inheritance) combined with composition.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles