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:
- The object itself
- Its prototype
- The prototype's prototype (and so on)
- 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:
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:
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:
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:
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:
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:
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:
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:
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:
// 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
nullis 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.






Comments
Loading comments…