Learning Objectives

  • Understand prototypal inheritance and the prototype chain
  • Master ES6 class syntax and inheritance
  • Use super to call parent methods
  • Implement method overriding
  • Apply inheritance to real-world problems

What is Inheritance?

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.

Constructor Functions and Prototypes

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.

Implementing Inheritance with Constructor Functions

// 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: Modern Inheritance

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

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.

Method Overriding

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

Real-World Example: User Roles

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

The Prototype Chain

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 Methods and Properties

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

Static Methods in Inheritance

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"

Common Pitfalls

1. Forgetting super() in Constructor

// 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;
    }
}

2. Modifying Shared Prototype Properties

// 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
    }
}

3. Overriding Without Calling super

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;
    }
}

Best Practices

1. Favor Composition Over Inheritance

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()
        };
    }
}

2. Keep Inheritance Hierarchies Shallow

Limit inheritance to 2-3 levels maximum. Deep hierarchies are hard to maintain and understand.

3. Use instanceof Carefully

// 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)

4. Document Your Class Hierarchy

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;
    }
}

When to Use Inheritance

Use inheritance when:

  • You have a clear "is-a" relationship (Dog is-a Animal)
  • Child classes are specialized versions of the parent
  • You want to share common behavior across related classes
  • You're modeling real-world hierarchies

Avoid inheritance when:

  • You have a "has-a" relationship (use composition instead)
  • The hierarchy would be more than 3 levels deep
  • Classes don't share meaningful behavior
  • You're just trying to reuse code (consider mixins or composition)

Key Takeaways

  • JavaScript uses prototypal inheritance, not classical inheritance
  • ES6 classes provide clean syntax but use prototypes under the hood
  • Use extends to create child classes
  • Always call super() before using this in child constructors
  • Use super.method() to call parent methods
  • Child classes can override parent methods
  • Keep inheritance hierarchies shallow (2-3 levels max)
  • Favor composition over inheritance for complex relationships
  • Static methods belong to the class, not instances
  • Understand the prototype chain for debugging