OOP in JavaScript (Object-oriented programming)
OOP in JavaScript organizes code into objects that bundle data and behavior, using a prototype chain for shared methods rather than copying state per instance.
Theory
TL;DR
- Prototype chain is a lookup path:
dog -> Dog.prototype -> Animal.prototype -> Object.prototype. First match wins. - ES6
classsyntax is syntactic sugar over prototypes. Under the hood, nothing changed. - Four principles: encapsulation (hide internals), inheritance (share behavior), polymorphism (same interface, different implementation), abstraction (hide complexity)
- Use classes for readable hierarchies,
Object.create()for dynamic sharing, factory functions when inheritance is not needed
Quick example
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
}
class Dog extends Animal {
speak() { return `${this.name} barks`; } // overrides parent method
}
const dog = new Dog('Rex');
console.log(dog.speak()); // "Rex barks"
// Under the hood:
// dog.__proto__ === Dog.prototype
// Dog.prototype.__proto__ === Animal.prototypeDog overrides speak() from Animal. When you call dog.speak(), the engine finds it on Dog.prototype first and stops. That is the prototype chain.
How the prototype chain works
When you access dog.speak(), V8 walks: the dog object itself, then Dog.prototype, then Animal.prototype, then Object.prototype, then null. First match wins.
This is delegation, not copying. All Dog instances share one speak method on Dog.prototype. Java copies state into each instance. JavaScript does not. Classes give you a cleaner way to set up that chain, but the chain was always there.
The four OOP principles
Encapsulation hides internals and exposes a controlled interface. Private fields with # do this at the language level since ES2022:
class BankAccount {
#balance = 0;
constructor(initial) { this.#balance = initial; }
deposit(amount) { if (amount > 0) this.#balance += amount; }
getBalance() { return this.#balance; }
}
const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// account.#balance -> SyntaxError: private field outside classInheritance lets a child class reuse and extend a parent. The child must call super() before using this:
class Animal {
constructor(name) { this.name = name; }
move() { return `${this.name} moves`; }
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // must come before this.breed
this.breed = breed;
}
fetch() { return `${this.name} fetches the ball`; }
}Polymorphism means the same method name behaves differently depending on which object calls it:
class Shape {
area() { throw new Error('area() not implemented'); }
}
class Circle extends Shape {
constructor(r) { super(); this.r = r; }
area() { return Math.PI * this.r ** 2; }
}
class Square extends Shape {
constructor(s) { super(); this.s = s; }
area() { return this.s ** 2; }
}
[new Circle(5), new Square(4)].forEach(s => console.log(s.area().toFixed(2)));
// 78.54
// 16.00Abstraction hides complexity behind a simple interface. The caller does not need to know if it is MySQL or PostgreSQL, just call query().
Composition vs inheritance
Inheritance works for true "is-a" relationships. A Dog is an Animal. But when you need to mix behaviors from multiple unrelated sources, inheritance breaks down. Composition builds objects by combining smaller pieces:
const canSwim = (state) => ({ swim: () => `${state.name} swims` });
const canFly = (state) => ({ fly: () => `${state.name} flies` });
const Duck = (name) => {
const state = { name };
return Object.assign({}, canSwim(state), canFly(state));
};
const duck = Duck('Donald');
console.log(duck.swim()); // "Donald swims"
console.log(duck.fly()); // "Donald flies"Hierarchies deeper than 3-4 levels tend to become fragile. When you find yourself wanting super.super, that is a sign to flatten with composition.
Prototypes before ES6
ES6 classes did not invent anything new. Here is the same pattern in ES5:
function Person(name) { this.name = name; }
Person.prototype.greet = function() { return `Hi, I'm ${this.name}`; };
function Developer(name, lang) {
Person.call(this, name);
this.lang = lang;
}
Developer.prototype = Object.create(Person.prototype);
Developer.prototype.constructor = Developer;
Developer.prototype.code = function() { return `${this.name} writes ${this.lang}`; };
const dev = new Developer('Anna', 'JavaScript');
console.log(dev.greet()); // "Hi, I'm Anna"
console.log(dev.code()); // "Anna writes JavaScript"You will still see this in older codebases. The ES6 class version compiles to something close to this.
When to use OOP
- Shared state across multiple methods and a clear "is-a" relationship: classes with inheritance
- Behavior from multiple unrelated sources: composition
- No shared state, just grouped logic: factory functions or plain objects
- More than 4-5 levels of inheritance: redesign with composition
Common mistakes
Forgetting super() before this in a child constructor:
// Wrong - throws ReferenceError
class Child extends Parent {
constructor() { this.prop = 'child'; }
}
// Right
class Child extends Parent {
constructor() { super(); this.prop = 'child'; }
}The parent constructor is responsible for creating the this object when you use extends. Until super() runs, this does not exist.
Mutating a shared prototype:
const proto = { greet() { return `Hi, ${this.name}`; } };
const a = Object.create(proto);
const b = Object.create(proto);
proto.sayBye = () => 'Bye'; // both a and b now have sayBye
console.log(a.sayBye()); // "Bye"
console.log(b.sayBye()); // "Bye"This is prototype pollution. The lodash CVE-2018-3721 exploited exactly this mechanism. Freeze shared objects with Object.freeze(proto) or avoid mutable shared prototypes entirely.
Private fields with older bundlers:
class Secure { #secret = 42; }
// Webpack below v5 cannot parse thisFall back to WeakMap if you cannot update your toolchain:
const _secrets = new WeakMap();
class Secure {
constructor() { _secrets.set(this, 42); }
getSecret() { return _secrets.get(this); }
}Class field arrows vs prototype methods:
class Counter {
count = 0;
increment = () => { this.count++; }; // one function per instance (more memory)
decrement() { this.count--; } // shared on prototype (cheaper)
}Neither is wrong. Arrow fields fix this binding when passing as callbacks but cost more memory per instance. Regular methods are cheaper but need explicit .bind() at the call site.
Deep prototype chains and lookup performance:
V8 inline caches work best with flat structures. Chains longer than 5 levels cause cache misses on property lookups. This matters in hot loops; in typical application code the difference is negligible.
How V8 handles this internally
V8 stores each object's [[Prototype]] as a hidden pointer. Property access triggers OrdinaryGetPrototypeOf traversal up the chain. V8 caches repeated lookups in hidden classes (shapes) for speed. new Dog() sets [[Prototype]] to Dog.prototype via Object.setPrototypeOf. That is all class does under the hood.
Real-world usage
- React:
class Component extends React.Componentfor lifecycle methods.PureComponentis justComponentwith a customshouldComponentUpdateon its prototype. - Node.js streams:
Readable,Transform,Duplexall chain through prototypes.Transform extends Readable. - Express: middleware as prototype chains;
app.use()delegates to a router prototype. - Lodash: utility mixins via
Object.assign(proto, mixins)for shared methods.
Follow-up questions
Q: What is the difference between class fields and prototype methods?
A: Class fields create per-instance own properties, so each object gets its own copy in memory. Prototype methods are shared: one function on the prototype, every instance uses the same one.
Q: Why does super() have to come before this in a child constructor?
A: When you use extends, the parent constructor is responsible for creating the this object. Until super() runs, this does not exist. Accessing it throws ReferenceError.
Q: How does Object.create(null) differ from {}?
A: {} has Object.prototype in its chain, so it inherits toString, hasOwnProperty, and the rest. Object.create(null) has no prototype at all. Useful for pure dictionaries where inherited keys would cause bugs.
Q: Why does instanceof sometimes give wrong results with Object.create()?
A: instanceof checks whether the constructor's prototype appears anywhere in the object's prototype chain. If you wire up a chain manually without connecting it to the constructor, instanceof will not find it.
Q: (Senior) How would you implement private state without # fields, and what is the tradeoff?
A: Use a WeakMap keyed by instance. Entries are garbage-collected when the instance dies, so no memory leak. Symbols as keys also work but are visible via Object.getOwnPropertySymbols(). The # syntax is truly private and V8 hashes field names to prevent collision across bundles.
Examples
Basic: prototype chain in action
class Vehicle {
constructor(make) { this.make = make; }
describe() { return `This is a ${this.make}`; }
}
class Car extends Vehicle {
constructor(make, model) {
super(make);
this.model = model;
}
describe() {
return `${super.describe()} ${this.model}`; // calls Vehicle.describe()
}
}
const car = new Car('Toyota', 'Camry');
console.log(car.describe()); // "This is a Toyota Camry"
// Verify the chain manually
console.log(Object.getPrototypeOf(car) === Car.prototype); // true
console.log(Object.getPrototypeOf(Car.prototype) === Vehicle.prototype); // truesuper.describe() walks up to Vehicle.prototype and calls the parent method from inside the override. The manual chain checks confirm exactly what ES6 class sets up for you.
Intermediate: logger with composition
class Logger {
constructor(prefix) {
this.prefix = prefix;
this.logs = [];
}
log(message) {
const entry = `[${this.prefix}] ${message}`;
this.logs.push(entry);
console.log(entry);
return entry;
}
getLogs() { return [...this.logs]; } // return a snapshot, not the original array
}
class UserService {
constructor() {
this.logger = new Logger('UserService'); // composition: has-a Logger
}
login(user) { return this.logger.log(`Login: ${user.name}`); }
logout(user) { return this.logger.log(`Logout: ${user.name}`); }
}
const service = new UserService();
service.login({ name: 'Alice' });
service.logout({ name: 'Alice' });
console.log(service.logger.getLogs());
// ["[UserService] Login: Alice", "[UserService] Logout: Alice"]UserService uses Logger through composition, not inheritance. If logging requirements change, you swap or extend Logger without touching UserService. I have seen this pattern replace entire inheritance trees in production codebases where someone force-fit inheritance just to share a logger.
Advanced: prototype pollution
const sharedProto = { greet() { return `Hi, ${this.name}`; } };
const user1 = Object.create(sharedProto);
user1.name = 'Bob';
const user2 = Object.create(sharedProto);
user2.name = 'Carol';
// Mutating the shared prototype affects every object in the chain
sharedProto.isAdmin = () => true;
console.log(user1.isAdmin()); // true - expected
console.log(user2.isAdmin()); // true - user2 never asked for thisThis is how the lodash CVE-2018-3721 worked. An attacker-controlled JSON payload reached __proto__ and injected properties onto Object.prototype, affecting every object in the Node.js process. Freeze shared prototypes with Object.freeze(sharedProto), or use Object.create(null) for data containers that should not inherit anything.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.