Master Prototypal Inheritance and ES6 Classes
Inheritance is a mechanism that allows one object to acquire properties and methods from another object. It's a fundamental concept in object-oriented programming that promotes code reuse and establishes relationships between objects.
Unlike classical languages like Java or C++ that use class-based inheritance, JavaScript uses prototypal inheritance. Every object in JavaScript has an internal link to another object called its prototype. When you try to access a property on an object, JavaScript first looks at the object itself, then walks up the prototype chain until it finds the property or reaches the end of the chain.
// Simple prototype chain example
const animal = {
eats: true,
walk() {
console.log('Animal walks');
}
};
const rabbit = {
jumps: true
};
// Set animal as the prototype of rabbit
rabbit.__proto__ = animal;
console.log(rabbit.eats); // true (inherited from animal)
console.log(rabbit.jumps); // true (own property)
rabbit.walk(); // "Animal walks" (inherited method)
When we access rabbit.eats, JavaScript doesn't find it on the rabbit object, so it looks at rabbit's prototype (animal) and finds it there. This is the prototype chain in action.
Before ES6 classes, JavaScript developers used constructor functions and prototypes to implement inheritance:
// Constructor function
function Animal(name) {
this.name = name;
}
// Add method to prototype
Animal.prototype.speak = function() {
console.log(`${this.name} makes a sound`);
};
// Create instance
const dog = new Animal('Rex');
dog.speak(); // "Rex makes a sound"
// Check prototype chain
console.log(dog.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.constructor === Animal); // true
When you use new Animal('Rex'), JavaScript creates a new object and sets its prototype to Animal.prototype. This is how methods defined on the prototype are shared across all instances.
// Parent constructor
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function() {
console.log(`${this.name} makes a sound`);
};
// Child constructor
function Dog(name, breed) {
Animal.call(this, name); // Call parent constructor
this.breed = breed;
}
// Set up inheritance
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// Add child-specific method
Dog.prototype.bark = function() {
console.log(`${this.name} barks!`);
};
// Override parent method
Dog.prototype.speak = function() {
console.log(`${this.name} barks loudly`);
};
const rex = new Dog('Rex', 'German Shepherd');
rex.speak(); // "Rex barks loudly"
rex.bark(); // "Rex barks!"
This pattern works but is verbose and error-prone. That's why ES6 introduced class syntax.
ES6 classes provide a cleaner, more intuitive syntax for creating objects and implementing inheritance. Under the hood, they still use prototypes, but the syntax is much more readable:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a sound`);
}
move() {
console.log(`${this.name} moves`);
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // Call parent constructor
this.breed = breed;
}
// Override parent method
speak() {
console.log(`${this.name} barks`);
}
// Add new method
fetch() {
console.log(`${this.name} fetches the ball`);
}
}
const rex = new Dog('Rex', 'German Shepherd');
rex.speak(); // "Rex barks"
rex.move(); // "Rex moves" (inherited)
rex.fetch(); // "Rex fetches the ball"
The extends keyword sets up the prototype chain, and super() calls the parent constructor. This is much cleaner than the constructor function approach.
The super keyword is used to call methods on a parent class. It's essential for accessing parent functionality in child classes:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // MUST call super() before using 'this'
this.breed = breed;
}
speak() {
// Call parent method and extend it
const parentMessage = super.speak();
return `${parentMessage} - specifically, a bark!`;
}
}
const dog = new Dog('Buddy', 'Golden Retriever');
console.log(dog.speak()); // "Buddy makes a sound - specifically, a bark!"
Important: In a child class constructor, you must call super() before accessing this. This is because the parent constructor needs to initialize the object first.
Child classes can override parent methods to provide specialized behavior:
class Shape {
constructor(color) {
this.color = color;
}
draw() {
console.log(`Drawing a ${this.color} shape`);
}
getArea() {
return 0; // Default implementation
}
}
class Circle extends Shape {
constructor(color, radius) {
super(color);
this.radius = radius;
}
// Override draw method
draw() {
console.log(`Drawing a ${this.color} circle with radius ${this.radius}`);
}
// Override getArea method
getArea() {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(color, width, height) {
super(color);
this.width = width;
this.height = height;
}
draw() {
console.log(`Drawing a ${this.color} rectangle ${this.width}x${this.height}`);
}
getArea() {
return this.width * this.height;
}
}
const circle = new Circle('red', 5);
const rectangle = new Rectangle('blue', 10, 20);
circle.draw(); // "Drawing a red circle with radius 5"
console.log(circle.getArea()); // 78.54...
rectangle.draw(); // "Drawing a blue rectangle 10x20"
console.log(rectangle.getArea()); // 200
Let's build a practical user management system using inheritance:
class User {
constructor(name, email) {
this.name = name;
this.email = email;
this.createdAt = new Date();
}
getInfo() {
return `${this.name} (${this.email})`;
}
canEdit(resource) {
return false; // Default: no edit permission
}
canDelete(resource) {
return false; // Default: no delete permission
}
}
class Admin extends User {
constructor(name, email, department) {
super(name, email);
this.department = department;
this.role = 'admin';
}
canEdit(resource) {
return true; // Admins can edit everything
}
canDelete(resource) {
return true; // Admins can delete everything
}
getInfo() {
return `${super.getInfo()} - Admin (${this.department})`;
}
}
class Moderator extends User {
constructor(name, email, permissions) {
super(name, email);
this.permissions = permissions;
this.role = 'moderator';
}
canEdit(resource) {
return this.permissions.includes('edit');
}
canDelete(resource) {
return this.permissions.includes('delete');
}
getInfo() {
return `${super.getInfo()} - Moderator`;
}
}
// Usage
const regularUser = new User('Alice', 'alice@example.com');
const admin = new Admin('Bob', 'bob@example.com', 'IT');
const moderator = new Moderator('Charlie', 'charlie@example.com', ['edit']);
console.log(regularUser.getInfo()); // "Alice (alice@example.com)"
console.log(admin.getInfo()); // "Bob (bob@example.com) - Admin (IT)"
console.log(moderator.getInfo()); // "Charlie (charlie@example.com) - Moderator"
console.log(regularUser.canEdit()); // false
console.log(admin.canEdit()); // true
console.log(moderator.canEdit()); // true
console.log(moderator.canDelete()); // false
Understanding the prototype chain is crucial for mastering JavaScript inheritance:
class Animal {
speak() {
return 'sound';
}
}
class Dog extends Animal {
bark() {
return 'woof';
}
}
const dog = new Dog();
// Prototype chain:
// dog -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
console.log(dog.__proto__ === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null
// Check if object is instance of class
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Object); // true
// Check if property exists
console.log(dog.hasOwnProperty('bark')); // false (it's on prototype)
console.log('bark' in dog); // true (checks prototype chain)
Static members belong to the class itself, not instances:
class MathHelper {
static PI = 3.14159;
static add(a, b) {
return a + b;
}
static multiply(a, b) {
return a * b;
}
}
// Call static methods on the class
console.log(MathHelper.add(5, 3)); // 8
console.log(MathHelper.multiply(4, 2)); // 8
console.log(MathHelper.PI); // 3.14159
// Static methods are NOT available on instances
const helper = new MathHelper();
// helper.add(1, 2); // Error: helper.add is not a function
class Animal {
static kingdom = 'Animalia';
static getKingdom() {
return this.kingdom;
}
}
class Dog extends Animal {
static breed = 'Canis familiaris';
static getBreed() {
return this.breed;
}
}
console.log(Animal.getKingdom()); // "Animalia"
console.log(Dog.getKingdom()); // "Animalia" (inherited)
console.log(Dog.getBreed()); // "Canis familiaris"
// Bad - will throw error
class Dog extends Animal {
constructor(name, breed) {
this.breed = breed; // Error! Must call super() first
super(name);
}
}
// Good
class Dog extends Animal {
constructor(name, breed) {
super(name); // Call super() first
this.breed = breed;
}
}
// Bad - array is shared across all instances
class Team {
constructor(name) {
this.name = name;
}
}
Team.prototype.members = []; // Shared!
const team1 = new Team('A');
const team2 = new Team('B');
team1.members.push('Alice');
console.log(team2.members); // ['Alice'] - Oops!
// Good - array is instance-specific
class Team {
constructor(name) {
this.name = name;
this.members = []; // Each instance gets its own array
}
}
class Animal {
constructor(name) {
this.name = name;
this.energy = 100;
}
}
// Bad - loses parent initialization
class Dog extends Animal {
constructor(name, breed) {
// Forgot super()!
this.breed = breed;
}
}
// Good - preserves parent initialization
class Dog extends Animal {
constructor(name, breed) {
super(name); // Initializes name and energy
this.breed = breed;
}
}
Don't create deep inheritance hierarchies. Often, composition (combining objects) is better than inheritance:
// Instead of deep inheritance
class Animal {}
class Mammal extends Animal {}
class Dog extends Mammal {}
class GoldenRetriever extends Dog {} // Too deep!
// Consider composition
class Dog {
constructor(name) {
this.name = name;
this.abilities = {
canSwim: new SwimmingAbility(),
canFetch: new FetchingAbility()
};
}
}
Limit inheritance to 2-3 levels maximum. Deep hierarchies are hard to maintain and understand.
// instanceof checks the prototype chain
class Animal {}
class Dog extends Animal {}
const dog = new Dog();
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
// But be careful with cross-realm objects
// (objects from different iframes/windows)
Use JSDoc to document inheritance relationships:
/**
* Base class for all animals
*/
class Animal {
constructor(name) {
this.name = name;
}
}
/**
* Dog class
* @extends Animal
*/
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
}
Use inheritance when:
Avoid inheritance when:
extends to create child classessuper() before using this in child constructorssuper.method() to call parent methods