Skip to main content

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 class syntax 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

javascript
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.prototype

Dog 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:

javascript
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 class

Inheritance lets a child class reuse and extend a parent. The child must call super() before using this:

javascript
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:

javascript
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.00

Abstraction 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:

javascript
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:

javascript
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:

javascript
// 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:

javascript
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:

javascript
class Secure { #secret = 42; } // Webpack below v5 cannot parse this

Fall back to WeakMap if you cannot update your toolchain:

javascript
const _secrets = new WeakMap(); class Secure { constructor() { _secrets.set(this, 42); } getSecret() { return _secrets.get(this); } }

Class field arrows vs prototype methods:

javascript
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.Component for lifecycle methods. PureComponent is just Component with a custom shouldComponentUpdate on its prototype.
  • Node.js streams: Readable, Transform, Duplex all 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

javascript
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); // true

super.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

javascript
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

javascript
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 this

This 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?