JavaScript Prototypes and Prototypal Inheritance
Deep dive into JavaScript prototypes, prototype chain, and prototypal inheritance. Learn how objects inherit properties and methods in JavaScript.
JavaScript Prototypes and Prototypal Inheritance
Prototypes are the mechanism by which JavaScript objects inherit features from one another. Unlike classical inheritance in languages like Java or C++, JavaScript uses prototypal inheritance, making it unique and powerful.
Understanding Prototypes
Every JavaScript object has an internal property called [[Prototype]]
that references another object. This forms the basis of prototypal inheritance.
// Every object has a prototype
const obj = {};
console.log(Object.getPrototypeOf(obj)); // Object.prototype
// Arrays inherit from Array.prototype
const arr = [];
console.log(Object.getPrototypeOf(arr)); // Array.prototype
console.log(Object.getPrototypeOf(Array.prototype)); // Object.prototype
// Functions inherit from Function.prototype
function func() {}
console.log(Object.getPrototypeOf(func)); // Function.prototype
The Prototype Chain
When you access a property on an object, JavaScript looks up the prototype chain until it finds the property or reaches the end.
// Prototype chain in action
const animal = {
type: 'animal',
breathe() {
console.log('Breathing...');
},
};
const mammal = {
type: 'mammal',
feedMilk() {
console.log('Feeding milk...');
},
};
const dog = {
name: 'Rex',
bark() {
console.log('Woof!');
},
};
// Set up the prototype chain
Object.setPrototypeOf(mammal, animal);
Object.setPrototypeOf(dog, mammal);
// Accessing properties through the chain
console.log(dog.name); // 'Rex' (own property)
console.log(dog.type); // 'mammal' (from mammal)
dog.breathe(); // 'Breathing...' (from animal)
dog.feedMilk(); // 'Feeding milk...' (from mammal)
// Check the prototype chain
console.log(dog.hasOwnProperty('name')); // true
console.log(dog.hasOwnProperty('type')); // false
console.log('breathe' in dog); // true (found in chain)
Constructor Functions and Prototypes
Constructor functions are the traditional way to create objects with shared methods.
// Constructor function
function Person(name, age) {
// Instance properties
this.name = name;
this.age = age;
}
// Shared methods on prototype
Person.prototype.greet = function () {
return `Hello, I'm ${this.name}`;
};
Person.prototype.birthday = function () {
this.age++;
return `Happy birthday! Now ${this.age} years old`;
};
// Create instances
const john = new Person('John', 30);
const jane = new Person('Jane', 25);
console.log(john.greet()); // "Hello, I'm John"
console.log(jane.greet()); // "Hello, I'm Jane"
// Both instances share the same methods
console.log(john.greet === jane.greet); // true
// Check prototype
console.log(Object.getPrototypeOf(john) === Person.prototype); // true
console.log(john instanceof Person); // true
Understanding the Constructor Property
function Car(make, model) {
this.make = make;
this.model = model;
}
const myCar = new Car('Toyota', 'Camry');
// Constructor property points back to the constructor function
console.log(myCar.constructor === Car); // true
console.log(Car.prototype.constructor === Car); // true
// Be careful when replacing the prototype
Car.prototype = {
// Lost the constructor property!
drive() {
console.log('Driving...');
},
};
// Fix by adding constructor back
Car.prototype = {
constructor: Car,
drive() {
console.log('Driving...');
},
};
// Or use Object.defineProperty
Object.defineProperty(Car.prototype, 'constructor', {
enumerable: false,
writable: true,
configurable: true,
value: Car,
});
Object.create() Method
Object.create()
provides a more direct way to set up prototypal inheritance.
// Create object with specific prototype
const personProto = {
greet() {
return `Hello, I'm ${this.name}`;
},
introduce() {
return `My name is ${this.name} and I'm ${this.age} years old`;
},
};
// Create object with personProto as prototype
const john = Object.create(personProto);
john.name = 'John';
john.age = 30;
console.log(john.greet()); // "Hello, I'm John"
// With property descriptors
const jane = Object.create(personProto, {
name: {
value: 'Jane',
writable: true,
enumerable: true,
configurable: true,
},
age: {
value: 25,
writable: true,
enumerable: true,
configurable: true,
},
});
// Create object with null prototype
const nullProtoObj = Object.create(null);
console.log(nullProtoObj.toString); // undefined (no Object.prototype)
Classical Inheritance Pattern
Implementing inheritance with constructor functions:
// Parent constructor
function Animal(name) {
this.name = name;
this.energy = 100;
}
Animal.prototype.eat = function (amount) {
console.log(`${this.name} is eating`);
this.energy += amount;
};
Animal.prototype.sleep = function (hours) {
console.log(`${this.name} is sleeping`);
this.energy += hours * 10;
};
// Child constructor
function Dog(name, breed) {
// Call parent constructor
Animal.call(this, name);
this.breed = breed;
}
// Set up inheritance
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// Add methods to child
Dog.prototype.bark = function () {
console.log(`${this.name} says: Woof!`);
this.energy -= 10;
};
// Override parent method
Dog.prototype.eat = function (amount) {
// Call parent method
Animal.prototype.eat.call(this, amount);
console.log(`${this.name} wags tail while eating`);
};
// Usage
const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.eat(20); // "Buddy is eating" "Buddy wags tail while eating"
myDog.bark(); // "Buddy says: Woof!"
myDog.sleep(8); // "Buddy is sleeping"
console.log(myDog.energy); // 110
// Check inheritance
console.log(myDog instanceof Dog); // true
console.log(myDog instanceof Animal); // true
ES6 Classes and Prototypes
ES6 classes are syntactic sugar over prototypal inheritance.
// ES6 class syntax
class Vehicle {
constructor(type, wheels) {
this.type = type;
this.wheels = wheels;
}
describe() {
return `A ${this.type} with ${this.wheels} wheels`;
}
static compareWheels(v1, v2) {
return v1.wheels - v2.wheels;
}
}
class Car extends Vehicle {
constructor(make, model) {
super('car', 4);
this.make = make;
this.model = model;
}
describe() {
return `${super.describe()}: ${this.make} ${this.model}`;
}
honk() {
return 'Beep beep!';
}
}
// Under the hood, it's still prototypes
const myCar = new Car('Toyota', 'Camry');
console.log(myCar.describe()); // "A car with 4 wheels: Toyota Camry"
// Check prototypes
console.log(Object.getPrototypeOf(myCar) === Car.prototype); // true
console.log(Object.getPrototypeOf(Car.prototype) === Vehicle.prototype); // true
// Static methods are on the constructor
console.log(Vehicle.compareWheels(myCar, new Vehicle('bike', 2))); // 2
Advanced Prototype Patterns
Mixin Pattern
// Mixins for multiple inheritance behavior
const CanFly = {
fly() {
console.log(`${this.name} is flying at ${this.altitude}m`);
},
land() {
this.altitude = 0;
console.log(`${this.name} has landed`);
},
};
const CanSwim = {
swim() {
console.log(`${this.name} is swimming`);
},
dive(depth) {
console.log(`${this.name} dives to ${depth}m`);
},
};
// Base class
class Animal {
constructor(name) {
this.name = name;
this.altitude = 0;
}
}
// Mix in capabilities
class Duck extends Animal {
constructor(name) {
super(name);
}
}
// Apply mixins
Object.assign(Duck.prototype, CanFly, CanSwim);
const donald = new Duck('Donald');
donald.fly(); // "Donald is flying at 0m"
donald.swim(); // "Donald is swimming"
Factory Functions with Prototypes
// Factory function with shared methods
function createPerson(name, age) {
const person = Object.create(createPerson.prototype);
person.name = name;
person.age = age;
return person;
}
createPerson.prototype = {
greet() {
return `Hi, I'm ${this.name}`;
},
haveBirthday() {
this.age++;
return `Now I'm ${this.age}`;
},
};
const person1 = createPerson('Alice', 30);
console.log(person1.greet()); // "Hi, I'm Alice"
// Composition over inheritance
function withTimestamp(obj) {
return Object.assign(Object.create(Object.getPrototypeOf(obj)), obj, {
timestamp: Date.now(),
getAge() {
return Date.now() - this.timestamp;
},
});
}
const timedPerson = withTimestamp(person1);
Property Descriptors and Prototypes
// Define properties with specific behaviors
function BankAccount(balance) {
let _balance = balance;
Object.defineProperty(this, 'balance', {
get() {
return _balance;
},
set(value) {
if (value < 0) {
throw new Error('Balance cannot be negative');
}
_balance = value;
},
enumerable: true,
configurable: false,
});
}
BankAccount.prototype.deposit = function (amount) {
if (amount > 0) {
this.balance += amount;
}
};
const account = new BankAccount(1000);
console.log(account.balance); // 1000
account.deposit(500);
console.log(account.balance); // 1500
// account.balance = -100; // Error!
// Prevent prototype pollution
Object.freeze(BankAccount.prototype);
// BankAccount.prototype.hack = function() {}; // Error in strict mode
Performance Considerations
// Inefficient: Methods created for each instance
function InefficientPerson(name) {
this.name = name;
this.greet = function () {
// New function for each instance!
return `Hello, I'm ${this.name}`;
};
}
// Efficient: Methods shared via prototype
function EfficientPerson(name) {
this.name = name;
}
EfficientPerson.prototype.greet = function () {
return `Hello, I'm ${this.name}`;
};
// Memory comparison
const people1 = Array(10000)
.fill(null)
.map((_, i) => new InefficientPerson(`Person${i}`));
const people2 = Array(10000)
.fill(null)
.map((_, i) => new EfficientPerson(`Person${i}`));
// people1 has 10,000 greet functions
// people2 has 1 shared greet function
Common Pitfalls and Solutions
Accidental Prototype Modification
// Problem: Modifying shared references
function Collection() {}
Collection.prototype.items = []; // Shared array!
const c1 = new Collection();
const c2 = new Collection();
c1.items.push('item1');
console.log(c2.items); // ['item1'] - Oops!
// Solution: Initialize in constructor
function FixedCollection() {
this.items = []; // Each instance gets its own array
}
Lost Prototype Chain
// Problem: Overwriting prototype loses methods
function Original() {}
Original.prototype.method1 = function () {};
Original.prototype.method2 = function () {};
// This overwrites everything
Original.prototype = {
method3: function () {},
};
// method1 and method2 are lost!
// Solution: Add to existing prototype
Original.prototype.method3 = function () {};
// Or use Object.assign
Object.assign(Original.prototype, {
method3: function () {},
method4: function () {},
});
Forgetting new Keyword
function Person(name) {
// Guard against forgetting 'new'
if (!(this instanceof Person)) {
return new Person(name);
}
this.name = name;
}
// Works with or without 'new'
const p1 = new Person('John');
const p2 = Person('Jane'); // Still works!
Modern Patterns and Best Practices
Object.setPrototypeOf vs Object.create
// Object.create - preferred for new objects
const proto = {
greet() {
return `Hello, ${this.name}`;
},
};
const obj1 = Object.create(proto);
obj1.name = 'Object 1';
// Object.setPrototypeOf - can change existing prototype
const obj2 = { name: 'Object 2' };
Object.setPrototypeOf(obj2, proto);
// Performance: Object.create is generally faster
// Object.setPrototypeOf can deoptimize objects
Private Properties with WeakMaps
// True privacy with WeakMaps
const privateData = new WeakMap();
class SecureAccount {
constructor(balance) {
privateData.set(this, { balance });
}
get balance() {
return privateData.get(this).balance;
}
deposit(amount) {
const data = privateData.get(this);
data.balance += amount;
}
}
const account = new SecureAccount(1000);
console.log(account.balance); // 1000
console.log(account._balance); // undefined (truly private)
Debugging Prototypes
// Useful prototype debugging utilities
function inspectPrototypeChain(obj) {
const chain = [];
let current = obj;
while (current) {
chain.push({
constructor: current.constructor?.name || 'Object',
properties: Object.getOwnPropertyNames(current),
prototype: current,
});
current = Object.getPrototypeOf(current);
}
return chain;
}
// Check if property is inherited
function isInherited(obj, prop) {
return prop in obj && !obj.hasOwnProperty(prop);
}
// Get all properties including inherited
function getAllProperties(obj) {
const props = new Set();
let current = obj;
while (current) {
Object.getOwnPropertyNames(current).forEach((prop) => props.add(prop));
current = Object.getPrototypeOf(current);
}
return Array.from(props);
}
Conclusion
JavaScript's prototypal inheritance is a powerful and flexible system:
- Objects inherit directly from other objects
- Prototype chain enables property and method lookup
- Constructor functions and ES6 classes provide familiar patterns
- Object.create() offers direct prototype manipulation
- Performance benefits from shared methods
Key takeaways:
- Every object has a prototype (except Object.prototype)
- Properties are looked up through the prototype chain
- Methods should be added to prototypes for efficiency
- ES6 classes are syntactic sugar over prototypes
- Understanding prototypes is crucial for JavaScript mastery
Master prototypes to unlock the full power of JavaScript's object system!