Inheritance & Polymorphism

Difficulty: Intermediate

Inheritance in JavaScript classes is established using the extends keyword. When class Child extends Parent, the child class inherits all methods and properties defined on the parent's prototype. Under the hood, two prototype links are set up: Child.prototype.__proto__ points to Parent.prototype (so instances inherit methods), and Child.__proto__ points to Parent (so static methods are inherited too). This dual-link mechanism is what makes JavaScript's class inheritance work seamlessly.

The super keyword serves two purposes. Inside a constructor, super() calls the parent class constructor. This is mandatory in any subclass constructor - you must call super() before using this, or JavaScript will throw a ReferenceError. Outside the constructor, super.method() calls a method on the parent's prototype, which is essential when you override a method but still need the parent's behavior as part of your implementation.

Method overriding is how polymorphism manifests in JavaScript. When a child class defines a method with the same name as one in the parent, the child's version takes precedence. This happens naturally through the prototype chain - since the child's prototype is checked before the parent's, the child's method is found first. Polymorphism means that different objects can respond to the same method call in different ways, and method overriding is the primary mechanism for achieving this.

The instanceof operator checks whether an object's prototype chain includes a given constructor's prototype. It walks the chain from the object up to null, checking each __proto__ link against the constructor's prototype property. This is useful for type checking in runtime, but it has limitations - it does not work across different JavaScript realms (iframes, for example, have different global constructors).

JavaScript does not support multiple inheritance - a class can only extend one parent. Mixins offer a workaround. A mixin is typically a function that takes a base class and returns a new class extending it with additional behavior. By chaining mixin calls, you can compose multiple behaviors onto a single class without the diamond problem that plagues traditional multiple inheritance.

The composition vs inheritance debate is crucial in software design. Inheritance creates tight coupling - changes to the parent class ripple through all descendants. Composition, where objects contain instances of other objects and delegate behavior to them, creates looser coupling and more flexibility. The general guidance is to favor composition over inheritance. Use inheritance when there is a clear "is-a" relationship (a Dog is an Animal). Use composition when you want to combine behaviors (a Car has an Engine, has Wheels).

Code examples

extends and super in constructors

class Animal {
  constructor(name, sound) {
    this.name = name;
    this.sound = sound;
  }

  speak() {
    return `${this.name} says ${this.sound}`;
  }

  toString() {
    return `[Animal: ${this.name}]`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name, 'Woof');   // MUST call super before using this
    this.breed = breed;
  }

  fetch(item) {
    return `${this.name} fetches the ${item}`;
  }
}

const dog = new Dog('Rex', 'German Shepherd');
console.log(dog.speak());     // inherited method
console.log(dog.fetch('ball'));  // own method
console.log(dog.name);        // inherited property
console.log(dog.breed);       // own property
console.log(dog.toString());  // inherited toString

Dog extends Animal, inheriting speak() and toString(). The super(name, 'Woof') call invokes Animal's constructor, setting name and sound. Dog adds its own breed property and fetch method.

Method overriding and super.method()

class Shape {
  constructor(name) {
    this.name = name;
  }

  area() {
    return 0;
  }

  describe() {
    return `${this.name} with area ${this.area().toFixed(2)}`;
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super('Rectangle');
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }

  describe() {
    return super.describe() + ` (${this.width}x${this.height})`;
  }
}

class Square extends Rectangle {
  constructor(side) {
    super(side, side);
    this.name = 'Square';  // override name set by super
  }
}

const shapes = [new Shape('Unknown'), new Rectangle(4, 5), new Square(3)];

shapes.forEach(s => {
  console.log(s.describe());
});

// Polymorphism in action: same method call, different results
console.log(shapes.map(s => s.area()));

Each class overrides area() to provide its own calculation. Rectangle.describe() calls super.describe() to include the parent's logic, then appends dimensions. The shapes array demonstrates polymorphism - the same method call produces different output depending on the actual object type.

instanceof and prototype chain checking

class Vehicle {}
class Car extends Vehicle {}
class ElectricCar extends Car {}

const tesla = new ElectricCar();

console.log(tesla instanceof ElectricCar);  // direct
console.log(tesla instanceof Car);          // parent
console.log(tesla instanceof Vehicle);      // grandparent
console.log(tesla instanceof Object);       // root of all

// How instanceof works under the hood
function myInstanceOf(obj, Constructor) {
  let proto = Object.getPrototypeOf(obj);
  while (proto !== null) {
    if (proto === Constructor.prototype) return true;
    proto = Object.getPrototypeOf(proto);
  }
  return false;
}

console.log(myInstanceOf(tesla, Car));
console.log(myInstanceOf(tesla, Array));

instanceof walks the prototype chain. Since ElectricCar extends Car which extends Vehicle, tesla is an instance of all three. The myInstanceOf function demonstrates the underlying algorithm - it traverses __proto__ links looking for a match with the constructor's prototype.

Mixins and composition vs inheritance

// Mixin pattern: functions that add behavior to a class
const Serializable = (Base) => class extends Base {
  toJSON() {
    const obj = {};
    for (const key of Object.keys(this)) {
      obj[key] = this[key];
    }
    return JSON.stringify(obj);
  }

  static fromJSON(json) {
    return Object.assign(new this(), JSON.parse(json));
  }
};

const Timestamped = (Base) => class extends Base {
  constructor(...args) {
    super(...args);
    this.createdAt = new Date().toISOString().split('T')[0];
  }
};

// Compose mixins
class BaseUser {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  greet() {
    return `Hello, ${this.name}`;
  }
}

class User extends Timestamped(Serializable(BaseUser)) {}

const user = new User('Alice', 'alice@example.com');
console.log(user.greet());
console.log(user.toJSON());
console.log(typeof user.createdAt);
console.log(user instanceof BaseUser);

// Composition alternative
class Engine {
  start() { return 'Engine started'; }
}

class Wheels {
  roll() { return 'Wheels rolling'; }
}

class ComposedCar {
  constructor() {
    this.engine = new Engine();  // has-a Engine
    this.wheels = new Wheels();  // has-a Wheels
  }

  drive() {
    return `${this.engine.start()} & ${this.wheels.roll()}`;
  }
}

const car = new ComposedCar();
console.log(car.drive());

Mixins compose behavior by wrapping base classes in higher-order functions. User inherits from BaseUser, Serializable, and Timestamped simultaneously. The ComposedCar example shows the alternative: instead of inheriting from Engine and Wheels, the car contains them and delegates. This is composition over inheritance.

Key points

Concepts covered

extends, super, method overriding, polymorphism, instanceof, mixins, composition vs inheritance