Introduction: The Object-Oriented JavaScript Nobody Teaches You
JavaScript's approach to objects and inheritance is fundamentally different from classical OOP languages like Java or C++, and this difference confuses the hell out of developers. You've probably written class syntax in modern JavaScript and assumed it works like classes in other languages. It doesn't. Under the hood, JavaScript uses prototypal inheritance—a more flexible but initially confusing system where objects inherit directly from other objects, not from blueprint classes. The class keyword introduced in ES6 is syntactic sugar over this prototype system, which means if you don't understand prototypes, you don't really understand JavaScript objects.
Here's the uncomfortable truth: most JavaScript developers write object-oriented code without understanding how it actually works. They copy patterns from tutorials, use class because it looks familiar, and run into bizarre bugs when inheritance doesn't behave as expected. Why does this lose context? Why can't you easily call a parent method? Why does instanceof sometimes lie? The answers live in the prototype chain. This guide goes deep on JavaScript's object system—from raw prototypes to ES6 classes to modern inheritance patterns. We'll cover what actually happens when you create an object, how the prototype chain resolves property lookups, the performance implications of different patterns, and when to use (or avoid) inheritance entirely. No hand-waving, no "just use classes"—we're going to understand this properly.
Understanding Prototypes: How JavaScript Objects Really Work
Every JavaScript object has a hidden internal property called [[Prototype]] (accessible via __proto__ or Object.getPrototypeOf()) that references another object. When you try to access a property on an object and it doesn't exist, JavaScript automatically looks up the prototype chain—checking the object's prototype, then that prototype's prototype, and so on until it finds the property or reaches null. This is prototypal inheritance in action, and it's more dynamic than classical inheritance because you can modify prototypes at runtime, affecting all objects that inherit from them.
Let's see this in practice with raw prototype manipulation before we add any syntactic sugar:
// Creating objects with prototypes manually
const animal = {
makeSound() {
console.log('Some generic sound');
},
eat(food) {
console.log(`Eating ${food}`);
}
};
// Create a new object with animal as its prototype
const dog = Object.create(animal);
dog.breed = 'Labrador';
dog.makeSound = function() {
console.log('Woof!');
};
console.log(dog.breed); // 'Labrador' - own property
dog.makeSound(); // 'Woof!' - own method (shadows prototype method)
dog.eat('kibble'); // 'Eating kibble' - inherited from prototype
// Check the prototype chain
console.log(Object.getPrototypeOf(dog) === animal); // true
console.log(dog.hasOwnProperty('breed')); // true
console.log(dog.hasOwnProperty('eat')); // false - it's on the prototype
// You can even modify the prototype after object creation
animal.sleep = function() {
console.log('Zzz...');
};
dog.sleep(); // 'Zzz...' - dog now has this method via prototype
// The prototype chain continues
console.log(Object.getPrototypeOf(animal)); // Object.prototype
console.log(Object.getPrototypeOf(Object.prototype)); // null - end of chain
This is pure prototypal inheritance—no classes, no constructors, just objects inheriting from objects. It's actually more flexible than class-based systems because there's no rigid hierarchy. Want to change what a "dog" is capable of? Just modify the prototype. Want to create a new object that inherits from multiple prototypes? You can (though it's messy and not recommended). The problem is this flexibility comes at the cost of familiarity. Developers from classical OOP backgrounds see this and think "where's my class definition?"
Constructor functions were JavaScript's first attempt to make this feel more classical. Before ES6 classes, you'd create constructor functions (just regular functions called with new) that set up prototype chains automatically:
// Constructor function pattern (pre-ES6 standard)
function Animal(name) {
this.name = name;
// BAD: Don't put methods here - they get duplicated for each instance
// this.makeSound = function() { console.log('sound'); };
}
// GOOD: Put methods on the prototype - shared across all instances
Animal.prototype.makeSound = function() {
console.log('Some generic sound');
};
Animal.prototype.eat = function(food) {
console.log(`${this.name} is eating ${food}`);
};
// Create instances
const dog = new Animal('Rex');
const cat = new Animal('Whiskers');
dog.eat('kibble'); // 'Rex is eating kibble'
cat.eat('fish'); // 'Whiskers is eating fish'
// Both share the same methods (not duplicated)
console.log(dog.eat === cat.eat); // true - same function reference
console.log(dog.name === cat.name); // false - different values
// What 'new' actually does:
// 1. Creates a new empty object
// 2. Sets the new object's [[Prototype]] to Animal.prototype
// 3. Calls Animal function with 'this' bound to new object
// 4. Returns the new object (unless function explicitly returns something else)
// Manual equivalent without 'new':
function createAnimal(name) {
const obj = Object.create(Animal.prototype);
Animal.call(obj, name);
return obj;
}
The constructor pattern is verbose but explicit about what's happening. The new keyword does some magic (setting up the prototype chain and binding this), but it's understandable magic. The real issue is mixing instance properties (set in constructor) with prototype methods (set on Constructor.prototype). This split across two locations makes the code harder to organize and is one reason ES6 classes became popular.
Property lookup and shadowing is where things get interesting. When you set a property on an object, you're always setting it on that specific object—you never modify the prototype unless you explicitly do so:
const parent = {
value: 10,
getValue() {
return this.value;
}
};
const child = Object.create(parent);
console.log(child.value); // 10 - found on prototype
console.log(child.getValue()); // 10 - method from prototype, 'this' is child
child.value = 20; // Creates new property on child, doesn't modify parent
console.log(child.value); // 20 - own property shadows prototype property
console.log(parent.value); // 10 - unchanged
console.log(child.getValue()); // 20 - 'this' still refers to child
// This creates subtle bugs if you're not careful
const config = {
settings: { theme: 'dark' }
};
const userConfig = Object.create(config);
// Oops - this modifies the prototype's object!
userConfig.settings.theme = 'light';
console.log(config.settings.theme); // 'light' - we mutated the parent!
// Correct way: replace the entire property
userConfig.settings = { theme: 'light' }; // Creates new property on child
console.log(config.settings.theme); // 'dark' - parent unchanged
This shadowing behavior is crucial to understand. You can't "inherit and modify" a property—you either inherit it unchanged, or you shadow it completely with a new value. This trips up developers constantly, especially with nested objects and arrays.
ES6 Classes: Syntactic Sugar Over Prototypes
ES6 classes don't add new functionality to JavaScript—they're syntactic sugar over the constructor function pattern we just covered. But it's really good syntactic sugar that makes code more readable and catches common mistakes. Let's see how classes map to the underlying prototype system:
// ES6 class syntax
class Animal {
constructor(name) {
this.name = name; // Instance property
}
// Methods are automatically added to Animal.prototype
makeSound() {
console.log('Some generic sound');
}
eat(food) {
console.log(`${this.name} is eating ${food}`);
}
// Static methods belong to the class itself, not instances
static isAnimal(obj) {
return obj instanceof Animal;
}
// Getters and setters
get species() {
return 'Unknown';
}
set nickname(value) {
this._nickname = value;
}
get nickname() {
return this._nickname || this.name;
}
}
// This is equivalent to:
function Animal(name) {
this.name = name;
}
Animal.prototype.makeSound = function() { /* ... */ };
Animal.prototype.eat = function() { /* ... */ };
Animal.isAnimal = function(obj) { /* ... */ };
// But much cleaner to read and write
// Usage is identical
const dog = new Animal('Rex');
dog.eat('kibble');
console.log(Animal.isAnimal(dog)); // true
// Class inheritance with extends
class Dog extends Animal {
constructor(name, breed) {
super(name); // MUST call parent constructor first
this.breed = breed;
}
// Override parent method
makeSound() {
console.log('Woof!');
}
// Add new method
fetch() {
console.log(`${this.name} is fetching`);
}
// Call parent method explicitly
callParent() {
super.makeSound(); // 'Some generic sound'
}
// Override getter
get species() {
return 'Canis familiaris';
}
}
const labrador = new Dog('Buddy', 'Labrador');
labrador.makeSound(); // 'Woof!' - overridden method
labrador.eat('treats'); // Inherited from Animal
labrador.fetch(); // Dog-specific method
console.log(labrador.species); // 'Canis familiaris'
The class syntax handles several pain points automatically. You don't have to manually set up prototype properties. You can't accidentally forget new (calling a class without new throws an error, unlike constructor functions). The extends keyword sets up the prototype chain correctly, and super gives you clean access to parent methods. These are real improvements over the old pattern.
But classes have limitations and gotchas that stem from being sugar over prototypes:
// Gotcha 1: 'this' binding still causes problems
class Counter {
constructor() {
this.count = 0;
}
increment() {
this.count++;
}
getCount() {
return this.count;
}
}
const counter = new Counter();
const incrementFn = counter.increment;
incrementFn(); // Error! 'this' is undefined in the extracted method
// Solutions:
// Option 1: Bind in constructor
class Counter {
constructor() {
this.count = 0;
this.increment = this.increment.bind(this);
}
increment() { this.count++; }
}
// Option 2: Arrow function (creates per-instance, not on prototype)
class Counter {
count = 0;
increment = () => {
this.count++;
};
}
// Option 3: Use arrow function when extracting
const incrementFn = () => counter.increment();
// Gotcha 2: Private fields are genuinely new (not sugar)
class BankAccount {
#balance = 0; // Private field (ES2022)
deposit(amount) {
this.#balance += amount;
}
getBalance() {
return this.#balance;
}
}
const account = new BankAccount();
account.deposit(100);
console.log(account.getBalance()); // 100
console.log(account.#balance); // SyntaxError! Cannot access private field
// Gotcha 3: Static initialization blocks (ES2022)
class Database {
static #connection = null;
static {
// Runs once when class is defined
this.#connection = this.#connect();
}
static #connect() {
console.log('Establishing connection...');
return { connected: true };
}
static getConnection() {
return this.#connection;
}
}
// Gotcha 4: Class fields are not on prototype
class Widget {
color = 'blue'; // Instance property, not prototype property
getColor() {
return this.color; // On prototype
}
}
const w1 = new Widget();
const w2 = new Widget();
console.log(w1.getColor === w2.getColor); // true - shared method
console.log(w1.color === w2.color); // true - same value
// But they're different properties, not shared references:
w1.color = 'red';
console.log(w2.color); // 'blue' - not affected
The honest assessment: classes make common patterns easier to write and read, but they don't solve the fundamental weirdness of JavaScript's object model. You still need to understand prototypes, this binding, and property lookup. Classes just give you a cleaner syntax for working with those concepts. The biggest win is for developers coming from other languages—classes make JavaScript feel more familiar without actually changing how it works.
Inheritance Patterns: Classical vs Prototypal vs Composition
Now that we understand the mechanics, let's talk about when and how to use inheritance. The classical OOP wisdom says to model your domain with inheritance hierarchies—Animal → Mammal → Dog → Labrador. JavaScript gives you the tools to do this with either prototypes or classes. But here's the controversial take: deep inheritance hierarchies are almost always wrong, regardless of language. Composition is usually better.
Classical inheritance hierarchies work in textbooks but fall apart in real code:
// The "proper" OOP way that seems logical
class Vehicle {
constructor(make, model) {
this.make = make;
this.model = model;
}
start() {
console.log('Starting vehicle');
}
}
class Car extends Vehicle {
constructor(make, model, doors) {
super(make, model);
this.doors = doors;
}
drive() {
console.log('Driving car');
}
}
class ElectricCar extends Car {
constructor(make, model, doors, batteryCapacity) {
super(make, model, doors);
this.batteryCapacity = batteryCapacity;
}
charge() {
console.log('Charging battery');
}
}
// Seems fine until you need a electric motorcycle
// Motorcycle isn't a Car, so it can't extend ElectricCar
// But you want the electric functionality...
// Now you're refactoring your entire hierarchy
class Motorcycle extends Vehicle {
// Can't reuse ElectricCar's charge() without awkward workarounds
}
// The gorilla/banana problem: you wanted a banana
// but you got a gorilla holding the banana and the entire jungle
The problem with deep hierarchies is rigidity. Real-world categories aren't clean trees—they're overlapping sets. An electric car shares traits with gas cars (they're both cars) and electric motorcycles (they're both electric). You can't model this cleanly in a strict hierarchy without duplication or contortion.
Composition over inheritance is the modern approach. Instead of "is-a" relationships (Dog IS-A Animal), use "has-a" relationships (Dog HAS behavior components):
// Behavior mixins - small, focused, reusable
const canEat = (state) => ({
eat(food) {
console.log(`Eating ${food}`);
state.energy += 10;
}
});
const canSleep = (state) => ({
sleep(hours) {
console.log(`Sleeping for ${hours} hours`);
state.energy += hours * 5;
}
});
const canBark = (state) => ({
bark() {
console.log(`${state.name} barks: Woof!`);
}
});
const canFly = (state) => ({
fly() {
console.log(`${state.name} is flying`);
state.energy -= 20;
}
});
// Factory function that composes behaviors
function createDog(name) {
const state = {
name,
energy: 100,
type: 'dog'
};
return Object.assign(
{},
canEat(state),
canSleep(state),
canBark(state),
{ getState: () => state }
);
}
function createBird(name) {
const state = {
name,
energy: 100,
type: 'bird'
};
return Object.assign(
{},
canEat(state),
canSleep(state),
canFly(state),
{ getState: () => state }
);
}
const dog = createDog('Rex');
dog.bark(); // Rex barks: Woof!
dog.eat('kibble');
console.log(dog.getState().energy); // 110
const bird = createBird('Tweety');
bird.fly(); // Tweety is flying
// bird.bark(); // Error - birds don't bark
// Easy to add new combinations without changing existing code
function createFlyingDog(name) {
const state = { name, energy: 100, type: 'flying-dog' };
return Object.assign(
{},
canEat(state),
canSleep(state),
canBark(state),
canFly(state),
{ getState: () => state }
);
}
This is more flexible than inheritance because you compose exactly the behaviors you need. No rigid hierarchy, no "is this a Car or a Vehicle" debates. The downside is less structure—someone needs to decide which behaviors to compose, and it's easy to create inconsistent objects. There's also no instanceof checking (though that's often a code smell anyway).
The class mixin pattern combines classes with composition:
// Mixin as a function that extends a class
const TimestampMixin = (Base) => class extends Base {
constructor(...args) {
super(...args);
this.createdAt = new Date();
}
getAge() {
return Date.now() - this.createdAt.getTime();
}
};
const SerializableMixin = (Base) => class extends Base {
toJSON() {
return JSON.stringify(this);
}
static fromJSON(json) {
const data = JSON.parse(json);
return new this(data);
}
};
// Base class
class User {
constructor({ name, email }) {
this.name = name;
this.email = email;
}
}
// Compose mixins
class TimestampedUser extends TimestampMixin(User) {}
class SerializableUser extends SerializableMixin(TimestampedUser) {}
const user = new SerializableUser({ name: 'Alice', email: 'alice@example.com' });
console.log(user.getAge()); // Time since creation
const json = user.toJSON();
const restored = SerializableUser.fromJSON(json);
// You can even create a composition helper
function mix(Base, ...mixins) {
return mixins.reduce((acc, mixin) => mixin(acc), Base);
}
class SuperUser extends mix(User, TimestampMixin, SerializableMixin) {}
This gives you the structure of classes with the flexibility of composition. It's more complex than pure composition but integrates better with class-based ecosystems (like TypeScript or React class components, though those are dated now).
When to use each pattern:
- Simple class inheritance (1-2 levels): When you have a genuine "is-a" relationship and a shallow hierarchy. Example: Button → PrimaryButton, IconButton.
- Prototypal delegation: When you want maximal flexibility and runtime modification. Example: configuration objects where you want defaults but allow overrides.
- Composition with factory functions: When you need flexible behavior combination and don't care about
instanceof. Example: game entities with various abilities. - Class mixins: When you want both class structure and composition. Example: extending framework classes with cross-cutting concerns like logging or caching.
The honest answer is most applications use a mix. Don't be dogmatic—understand the tradeoffs and pick the right tool for each situation.
The Prototype Chain in Practice: Performance and Debugging
Understanding the prototype chain isn't just academic—it has real performance and debugging implications. Let's talk about what actually happens at runtime and how to avoid common pitfalls.
Property lookup performance matters when you're accessing properties millions of times per second (game loops, data processing, hot paths). Each level in the prototype chain adds a lookup cost:
// Performance test: own property vs prototype property
const obj = {
ownProp: 'value'
};
Object.setPrototypeOf(obj, {
protoProp: 'value'
});
// Accessing own property is fastest
console.time('own');
for (let i = 0; i < 1000000; i++) {
obj.ownProp;
}
console.timeEnd('own'); // ~3ms
// Prototype property is slower (one additional lookup)
console.time('proto');
for (let i = 0; i < 1000000; i++) {
obj.protoProp;
}
console.timeEnd('proto'); // ~5ms
// Deep prototype chains are even slower
const deep = {};
let current = deep;
for (let i = 0; i < 10; i++) {
const next = {};
Object.setPrototypeOf(current, next);
current = next;
}
Object.setPrototypeOf(current, { deepProp: 'value' });
console.time('deep');
for (let i = 0; i < 1000000; i++) {
deep.deepProp; // 10 lookups to find this
}
console.timeEnd('deep'); // ~15ms
In practice, this rarely matters unless you're in a tight loop. Modern JavaScript engines optimize prototype lookups heavily with inline caches. But if profiling shows property access as a bottleneck, consider caching values in own properties or flattening your prototype chain.
hasOwnProperty vs in operator causes confusion:
const parent = { parentProp: 'parent value' };
const child = Object.create(parent);
child.childProp = 'child value';
// hasOwnProperty only checks own properties
console.log(child.hasOwnProperty('childProp')); // true
console.log(child.hasOwnProperty('parentProp')); // false
// 'in' operator checks entire prototype chain
console.log('childProp' in child); // true
console.log('parentProp' in child); // true
// This distinction matters for iteration
for (let key in child) {
console.log(key); // Logs both 'childProp' and 'parentProp'
}
// Use hasOwnProperty to filter
for (let key in child) {
if (child.hasOwnProperty(key)) {
console.log(key); // Only logs 'childProp'
}
}
// Better: use Object.keys() which only returns own properties
Object.keys(child).forEach(key => {
console.log(key); // Only 'childProp'
});
// Or use Object.entries() for key-value pairs
Object.entries(child).forEach(([key, value]) => {
console.log(`${key}: ${value}`);
});
Debugging prototype chains requires understanding the tools:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a sound`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
speak() {
console.log(`${this.name} barks`);
}
}
const myDog = new Dog('Rex', 'Labrador');
// Inspect prototype chain
console.log(Object.getPrototypeOf(myDog)); // Dog.prototype
console.log(Object.getPrototypeOf(Object.getPrototypeOf(myDog))); // Animal.prototype
console.log(Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(myDog)))); // Object.prototype
// Check if object is in prototype chain
console.log(myDog instanceof Dog); // true
console.log(myDog instanceof Animal); // true
console.log(myDog instanceof Object); // true
// Get all property names (including prototype chain)
function getAllProperties(obj) {
const props = new Set();
let current = obj;
while (current !== null) {
Object.getOwnPropertyNames(current).forEach(prop => props.add(prop));
current = Object.getPrototypeOf(current);
}
return Array.from(props);
}
console.log(getAllProperties(myDog));
// ['name', 'breed', 'constructor', 'speak', 'toString', 'valueOf', ...]
// Check where a property is defined
function findPropertyOwner(obj, prop) {
let current = obj;
while (current !== null) {
if (current.hasOwnProperty(prop)) {
return current;
}
current = Object.getPrototypeOf(current);
}
return null;
}
console.log(findPropertyOwner(myDog, 'name')); // myDog itself
console.log(findPropertyOwner(myDog, 'speak')); // Dog.prototype
Common mistakes to avoid:
// Mistake 1: Modifying Object.prototype (DON'T DO THIS)
Object.prototype.myMethod = function() {
return 'bad idea';
};
// Now EVERY object has this method, including {}
console.log({}.myMethod()); // 'bad idea'
// This pollutes the global namespace and breaks assumptions
// Mistake 2: Confusing prototype and __proto__
function Constructor() {}
const instance = new Constructor();
// Constructor.prototype is what instances inherit from
console.log(Object.getPrototypeOf(instance) === Constructor.prototype); // true
// instance.__proto__ is same as Object.getPrototypeOf(instance)
console.log(instance.__proto__ === Constructor.prototype); // true
// But Constructor.__proto__ is Function.prototype
console.log(Constructor.__proto__ === Function.prototype); // true
// Mistake 3: Forgetting 'new' with constructor functions
function User(name) {
this.name = name; // 'this' is undefined without 'new'
}
// Without 'new', 'this' is global object (or undefined in strict mode)
const user1 = User('Alice'); // Oops!
console.log(user1); // undefined
console.log(window.name); // 'Alice' (in browser) - polluted global!
// Classes prevent this mistake
class SafeUser {
constructor(name) {
this.name = name;
}
}
// const user2 = SafeUser('Bob'); // TypeError: Class constructor cannot be invoked without 'new'
// Mistake 4: Assuming instanceof works with all inheritance patterns
const animal = { type: 'animal' };
const dog = Object.create(animal);
dog.breed = 'Labrador';
// instanceof doesn't work with Object.create pattern
console.log(dog instanceof Animal); // Error: Animal is not a constructor
// Use different checks for prototypal inheritance
console.log(animal.isPrototypeOf(dog)); // true
console.log(Object.getPrototypeOf(dog) === animal); // true
The reality is debugging prototype chains is messy because the developer tools abstract away what's happening. Chrome DevTools shows you [[Prototype]] in the inspector, but understanding what you're seeing requires mental models we've built in this guide. When something goes wrong, trace the prototype chain manually using Object.getPrototypeOf() rather than assuming the visual representation is complete.
Modern JavaScript Patterns: When to Skip Classes Entirely
Here's the take that'll annoy some developers: you often don't need classes or prototypes at all. Modern JavaScript has evolved to support functional patterns that avoid OOP complexity entirely. For many applications, plain objects and pure functions are simpler, more testable, and easier to reason about than inheritance hierarchies.
The functional approach uses plain data structures and pure functions:
// Instead of class-based entities
class Player {
constructor(name, health) {
this.name = name;
this.health = health;
}
takeDamage(amount) {
this.health -= amount;
if (this.health < 0) this.health = 0;
}
heal(amount) {
this.health += amount;
if (this.health > 100) this.health = 100;
}
isAlive() {
return this.health > 0;
}
}
// Use plain objects and pure functions
const createPlayer = (name, health = 100) => ({
name,
health,
type: 'player'
});
const takeDamage = (player, amount) => ({
...player,
health: Math.max(0, player.health - amount)
});
const heal = (player, amount) => ({
...player,
health: Math.min(100, player.health + amount)
});
const isAlive = (player) => player.health > 0;
// Usage
let player = createPlayer('Alice');
player = takeDamage(player, 30);
player = heal(player, 15);
console.log(isAlive(player)); // true
// Benefits:
// 1. Immutability by default (easier to reason about state changes)
// 2. No 'this' binding issues
// 3. Easier to test (pure functions)
// 4. Easier to compose (just function composition)
// 5. Works great with state management libraries (Redux, Zustand)
This functional approach shines in applications where state management is centralized (like React apps with Redux) or when you want predictability over flexibility. The downside is verbosity—you're passing objects around explicitly rather than having implicit this binding.
Factory functions give you encapsulation without classes:
// Private state without classes
function createCounter(initialValue = 0) {
// Private variable (closure)
let count = initialValue;
// Privileged methods (have access to private state)
return {
increment() {
count++;
return count;
},
decrement() {
count--;
return count;
},
getValue() {
return count;
},
reset() {
count = initialValue;
}
};
}
const counter = createCounter(10);
console.log(counter.increment()); // 11
console.log(counter.getValue()); // 11
console.log(counter.count); // undefined - truly private!
// Can't access or modify count directly
counter.count = 999;
console.log(counter.getValue()); // still 11 - setting property didn't affect private count
// This is real encapsulation - the closure protects internal state
// Compare to class with "private" fields:
class ClassCounter {
#count; // Private field
constructor(initialValue = 0) {
this.#count = initialValue;
}
increment() {
return ++this.#count;
}
}
// Both achieve privacy, but factory functions use closures (older technique)
// Class private fields are newer and integrate better with TypeScript
Module pattern for organizing related functionality:
// Singleton module with private state and public API
const GameEngine = (() => {
// Private state
let running = false;
let score = 0;
const entities = [];
// Private functions
function updateEntities() {
entities.forEach(entity => {
if (entity.update) entity.update();
});
}
function render() {
console.log(`Score: ${score}, Entities: ${entities.length}`);
}
function gameLoop() {
if (!running) return;
updateEntities();
render();
requestAnimationFrame(gameLoop);
}
// Public API
return {
start() {
if (running) return;
running = true;
gameLoop();
},
stop() {
running = false;
},
addEntity(entity) {
entities.push(entity);
},
removeEntity(entity) {
const index = entities.indexOf(entity);
if (index > -1) entities.splice(index, 1);
},
addScore(points) {
score += points;
},
getScore() {
return score;
},
reset() {
running = false;
score = 0;
entities.length = 0;
}
};
})();
// Usage
GameEngine.start();
GameEngine.addEntity({ update: () => console.log('entity updating') });
GameEngine.addScore(10);
// Can't access private state
console.log(GameEngine.score); // undefined
console.log(GameEngine.getScore()); // 10
The module pattern creates a singleton with true private members. It's old-school but still useful for things like configuration managers, event buses, or global services where you want exactly one instance.
When to use functional vs OOP patterns:
// Use functional patterns when:
// 1. Working with immutable data
const users = [
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false }
];
const activateUser = (user) => ({ ...user, active: true });
const updatedUsers = users.map(user =>
user.id === 2 ? activateUser(user) : user
);
// 2. Building data transformation pipelines
const processData = (data) =>
data
.filter(item => item.value > 0)
.map(item => ({ ...item, normalized: item.value / 100 }))
.sort((a, b) => b.normalized - a.normalized)
.slice(0, 10);
// 3. Testing is a priority (pure functions are trivial to test)
const calculateTotal = (items, taxRate = 0.1) => {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
return subtotal * (1 + taxRate);
};
// Test: no mocking, no setup, just input -> output
console.assert(calculateTotal([{ price: 100 }]) === 110);
// Use OOP patterns when:
// 1. You need shared behavior across many instances
class WebSocketConnection {
constructor(url) {
this.url = url;
this.ws = new WebSocket(url);
this.setupHandlers();
}
setupHandlers() {
this.ws.onmessage = (event) => this.handleMessage(event);
this.ws.onerror = (error) => this.handleError(error);
}
handleMessage(event) {
console.log('Message:', event.data);
}
send(data) {
this.ws.send(JSON.stringify(data));
}
close() {
this.ws.close();
}
}
// 2. You're working with stateful entities that have lifecycle
class Animation {
constructor(element, keyframes, options) {
this.element = element;
this.animation = element.animate(keyframes, options);
this.state = 'idle';
}
play() {
this.animation.play();
this.state = 'playing';
}
pause() {
this.animation.pause();
this.state = 'paused';
}
onFinish(callback) {
this.animation.onfinish = () => {
this.state = 'finished';
callback();
};
}
}
// 3. You need polymorphism (different implementations of same interface)
class Logger {
log(message) {
throw new Error('Must implement log()');
}
}
class ConsoleLogger extends Logger {
log(message) {
console.log(`[Console] ${message}`);
}
}
class FileLogger extends Logger {
constructor(filename) {
super();
this.filename = filename;
}
log(message) {
// Write to file
console.log(`[File: ${this.filename}] ${message}`);
}
}
class RemoteLogger extends Logger {
constructor(endpoint) {
super();
this.endpoint = endpoint;
}
async log(message) {
await fetch(this.endpoint, {
method: 'POST',
body: JSON.stringify({ message, timestamp: Date.now() })
});
}
}
// Use any logger through same interface
function doWork(logger) {
logger.log('Starting work');
// ... work happens
logger.log('Work complete');
}
doWork(new ConsoleLogger());
doWork(new FileLogger('app.log'));
doWork(new RemoteLogger('https://api.example.com/logs'));
TypeScript considerations change the calculation. TypeScript's structural typing works well with both patterns, but classes give you better IDE support and refactoring tools:
// TypeScript interface - works with both patterns
interface Vehicle {
make: string;
model: string;
start(): void;
}
// Class implementation
class Car implements Vehicle {
constructor(
public make: string,
public model: string
) {}
start() {
console.log('Starting car');
}
}
// Factory function implementation
function createCar(make: string, model: string): Vehicle {
return {
make,
model,
start() {
console.log('Starting car');
}
};
}
// Both work with the interface
function startVehicle(vehicle: Vehicle) {
vehicle.start();
}
startVehicle(new Car('Toyota', 'Camry'));
startVehicle(createCar('Honda', 'Civic'));
// But classes give you:
// 1. Better autocomplete and type inference
// 2. Easier refactoring (rename class, find all usages)
// 3. Abstract classes and access modifiers
abstract class BaseRepository<T> {
protected items: T[] = [];
abstract validate(item: T): boolean;
add(item: T) {
if (this.validate(item)) {
this.items.push(item);
}
}
getAll(): T[] {
return [...this.items];
}
}
class UserRepository extends BaseRepository<{ id: number; email: string }> {
validate(user) {
return user.email.includes('@');
}
}
The brutal truth: most JavaScript developers use a mix. Classes for framework integration (React components used to be classes, Vue components can be), factory functions for utilities, functional patterns for data transformation, and module patterns for singletons. Don't be dogmatic—use the pattern that makes your specific code clearer and more maintainable.
Real-World Pitfalls: The Bugs You'll Actually Hit
Let's talk about the bugs that happen in production when you misunderstand prototypes and inheritance. These are the "wait, why is this happening?" moments that waste hours of debugging time.
The prototype mutation bug:
// Subtle bug that's hard to catch
function createUser(name) {
return {
name,
roles: []
};
}
const userPrototype = createUser('proto');
const user1 = Object.create(userPrototype);
user1.name = 'Alice'; // Shadows prototype property - good
const user2 = Object.create(userPrototype);
user2.name = 'Bob';
// BUG: Pushing to array modifies the prototype!
user1.roles.push('admin');
console.log(user1.roles); // ['admin']
console.log(user2.roles); // ['admin'] - OOPS!
// Both users share the same array from prototype
// This happens because arrays are reference types
// user1.roles doesn't shadow the prototype property,
// it accesses it and modifies the shared array
// Fix: Initialize arrays properly
function createUser(name) {
const user = Object.create(userPrototype);
user.name = name;
user.roles = []; // Create new array instance
return user;
}
// Or with classes:
class User {
constructor(name) {
this.name = name;
this.roles = []; // Each instance gets own array
}
}
// DON'T do this:
class User {
roles = []; // This LOOKS like instance property but...
constructor(name) {
this.name = name;
}
}
// Actually IS an instance property (this is fine in modern JS)
// But in older patterns with prototype assignment:
User.prototype.roles = []; // BAD - shared array!
The lost 'this' context:
class DataFetcher {
constructor(apiUrl) {
this.apiUrl = apiUrl;
this.cache = new Map();
}
async fetchData(endpoint) {
if (this.cache.has(endpoint)) {
return this.cache.get(endpoint);
}
const response = await fetch(`${this.apiUrl}/${endpoint}`);
const data = await response.json();
this.cache.set(endpoint, data);
return data;
}
}
const fetcher = new DataFetcher('https://api.example.com');
// Works fine
fetcher.fetchData('users').then(console.log);
// BUG: Lost 'this' context
const fetchUsers = fetcher.fetchData;
fetchUsers('users'); // TypeError: Cannot read property 'cache' of undefined
// This happens because fetchData is called without context
// 'this' becomes undefined (strict mode) or global object
// Fix 1: Bind in constructor
class DataFetcher {
constructor(apiUrl) {
this.apiUrl = apiUrl;
this.cache = new Map();
this.fetchData = this.fetchData.bind(this);
}
// ... rest
}
// Fix 2: Arrow function (creates per-instance)
class DataFetcher {
constructor(apiUrl) {
this.apiUrl = apiUrl;
this.cache = new Map();
}
fetchData = async (endpoint) => {
// Arrow function preserves 'this' from constructor
if (this.cache.has(endpoint)) {
return this.cache.get(endpoint);
}
// ... rest
};
}
// Fix 3: Use arrow function when extracting
const fetchUsers = (endpoint) => fetcher.fetchData(endpoint);
// Fix 4: Call with correct context
const fetchUsers = fetcher.fetchData.bind(fetcher);
The super() timing bug:
// BUG: Accessing 'this' before super()
class Parent {
constructor(name) {
this.name = name;
}
}
class Child extends Parent {
constructor(name, age) {
this.age = age; // ReferenceError: Must call super constructor
super(name);
}
}
// Fix: Call super() first
class Child extends Parent {
constructor(name, age) {
super(name);
this.age = age; // Now 'this' is available
}
}
// Related bug: Returning from constructor
class Parent {
constructor() {
this.value = 10;
}
}
class Child extends Parent {
constructor() {
super();
return { different: 'object' }; // Returns different object
}
}
const child = new Child();
console.log(child instanceof Child); // false!
console.log(child.value); // undefined
console.log(child.different); // 'object'
// When constructor returns an object, that becomes the instance
// This breaks instanceof and inheritance
The method override footgun:
class Parent {
method() {
console.log('Parent method');
}
callMethod() {
this.method(); // Dynamic dispatch - calls child version if overridden
}
}
class Child extends Parent {
method() {
console.log('Child method');
super.method(); // Call parent version
}
}
const child = new Child();
child.callMethod(); // Logs: "Child method" then "Parent method"
// BUG: Forgetting to call super can break functionality
class Child extends Parent {
method() {
console.log('Child method');
// Forgot super.method() - parent logic doesn't run!
}
}
// This is especially problematic with lifecycle methods:
class Component {
componentDidMount() {
this.setupEventListeners();
}
}
class MyComponent extends Component {
componentDidMount() {
this.loadData();
// BUG: Forgot super.componentDidMount()
// Event listeners never set up!
}
}
// Fix: Always call super in lifecycle methods
class MyComponent extends Component {
componentDidMount() {
super.componentDidMount();
this.loadData();
}
}
The instanceof lie:
// instanceof checks prototype chain, which can be manipulated
class Animal {}
class Dog extends Animal {}
const dog = new Dog();
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
// But instanceof can give false negatives
const plainDog = { breed: 'Labrador' };
console.log(plainDog instanceof Dog); // false - not created with 'new'
// And false positives with cross-realm objects
// If Dog class is defined in an iframe and object comes from parent:
const iframeDog = iframe.contentWindow.createDog();
console.log(iframeDog instanceof Dog); // false! Different Dog class
// instanceof breaks with Object.create
const dogPrototype = Dog.prototype;
const createDog = () => Object.create(dogPrototype);
const dog2 = createDog();
console.log(dog2 instanceof Dog); // true - this works
// But if you change the prototype...
Dog.prototype = {}; // Replace prototype
console.log(dog instanceof Dog); // false! Old instances break
console.log(new Dog() instanceof Dog); // true - new instances work
// Better: use duck typing or Symbol.hasInstance
class Animal {
static [Symbol.hasInstance](instance) {
return instance && typeof instance.makeSound === 'function';
}
}
const dog = { makeSound: () => 'woof' };
console.log(dog instanceof Animal); // true - duck typing!
The property enumeration trap:
class User {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}
const user = new User('Alice');
// Methods on prototype aren't enumerable by default
console.log(Object.keys(user)); // ['name'] - no 'getName'
for (let key in user) {
console.log(key); // Logs 'name' and 'getName'
}
// But for...in includes prototype properties!
// Always use hasOwnProperty when iterating:
for (let key in user) {
if (user.hasOwnProperty(key)) {
console.log(key); // Only 'name'
}
}
// Or use Object.keys() which only returns own properties
Object.keys(user).forEach(key => {
console.log(key); // Only 'name'
});
// Gotcha: Class fields vs methods
class User {
role = 'user'; // Own property (enumerable)
getRole() { // Prototype method (not enumerable)
return this.role;
}
getName = () => { // Own property (enumerable) - arrow function
return this.name;
}
}
const user = new User('Alice');
console.log(Object.keys(user)); // ['role', 'name', 'getName']
// Arrow functions are own properties, regular methods aren't!
These bugs share a common theme: they happen when the mental model (classes work like Java/C++) doesn't match the reality (prototypal inheritance with dynamic dispatch). The fix is always understanding what's really happening under the hood, which we've built throughout this guide.
Conclusion: Mastering JavaScript's Object Model
After this deep dive, you should understand that JavaScript's object system is both simpler and more complex than it first appears. Simpler because it's just objects linking to other objects—no metaclasses, no interfaces, no multiple inheritance complications. More complex because that simplicity allows so many patterns that choosing the right one requires judgment.
The core lessons: Prototypes are the foundation. Classes are syntax, prototypes are semantics. When you write class, you're creating a constructor function and setting up prototype chains. When you use extends, you're linking prototype objects. When you call super(), you're traversing that chain. Understanding this makes debugging easier and prevents entire categories of bugs. Inheritance is a tool, not a religion. Deep class hierarchies cause more problems than they solve. Composition—whether through mixins, factory functions, or plain object spreading—usually produces more flexible, maintainable code. Use inheritance for genuine "is-a" relationships (Button → PrimaryButton), but default to composition for behavior sharing.
Context matters more than dogma. The "best" pattern depends on your specific situation. Building a React app with state management? Functional patterns with plain objects work great. Creating a library with complex stateful objects? Classes provide structure and familiar API. Writing utilities? Pure functions are simpler. Don't let ideology override pragmatism—use the pattern that makes your code clearer to your team.
The modern JavaScript landscape gives you options that didn't exist a decade ago. Private fields genuinely hide implementation details. Static initialization blocks run code at class definition time. Decorators (proposed) will add metadata and aspect-oriented programming. But none of these change the fundamental prototype system—they just add conveniences on top. Learn the foundation, and the conveniences make sense. Skip the foundation, and you're forever cargo-culting patterns you don't understand.
Here's your action plan: First, experiment with raw prototypes using Object.create() and Object.getPrototypeOf() until the chain makes intuitive sense. Second, build something small with ES6 classes to internalize the syntax. Third, refactor a class-based design to use composition and feel the difference in flexibility. Fourth, profile the performance of different patterns in your actual application (not synthetic benchmarks) to see what matters in practice. Finally, document your team's chosen patterns with rationale so everyone codes consistently.
The JavaScript community has moved through phases—constructor functions, then Backbone/Angular classes, then React's mixins, then ES6 classes, now functional components and hooks. Each wave claimed to solve OOP in JavaScript once and for all. None did, because there isn't one solution. JavaScript's flexibility is its strength. Master the fundamentals in this guide, then choose the patterns that fit your context. That's the real skill—not memorizing syntax, but understanding tradeoffs well enough to make informed decisions. Now go write some code and break some prototypes. It's the only way to really learn this stuff.