OOP Interview Prep
Inheritance

Multilevel Inheritance

Grandparent, Parent, Child Chains

LinkedIn Hook

Three levels deep. One super() call. A bug that took me two hours to find.

Multilevel inheritance looks simple on paper: Grandparent -> Parent -> Child. The child gets everything. The grandparent's speak() method, the parent's move() method, all of it. Easy.

Until you change one method in the middle layer. Now the grandparent's behavior is gone — silently, without a single error. The override swallowed it. And if you didn't know how JavaScript's method resolution order walks the prototype chain, you'd never know what happened.

This is why interviewers ask about multilevel inheritance. Not to test whether you know the syntax. The syntax is one line: class Child extends Parent. They want to know if you understand what happens under the hood when a method is called on the deepest class in a chain — and why going beyond three levels is a maintainability trap most experienced engineers have fallen into at least once.

In Lesson 4.3, you'll see how the prototype chain resolves method calls step by step, how super() threads through multiple levels, and exactly where the three-level rule comes from — with a real-world example to make it stick.

Read the full lesson -> [link]

#OOP #JavaScript #Inheritance #InterviewPrep


Multilevel Inheritance thumbnail


What You'll Learn

  • How the Grandparent -> Parent -> Child chain works in JavaScript
  • How method resolution order (MRO) walks the prototype chain
  • How super() threads through multiple levels and when to call it
  • Why stopping at three levels of inheritance is a practical rule
  • A real-world example showing where multilevel inheritance fits naturally
  • What happens when you accidentally break the chain by forgetting super()

The Analogy — Family Traits Passed Down Through Generations

Think of a family. A grandparent passes down eye color and a knack for cooking. A parent inherits both, then adds fluency in a second language. A child inherits all three: eye color, cooking skills, and the second language — plus whatever new traits they develop themselves.

Each generation builds on what came before. None of them need to rediscover the grandparent's traits from scratch. That layering is exactly what multilevel inheritance does in code.

But here is the part the analogy also gets right: if a parent decides to "override" the grandparent's cooking style completely — without incorporating any of the original technique — the child will never know the grandparent's way existed. The parent's version fully replaces it. In JavaScript, that is what happens when a method is overridden without calling super. The grandparent's behavior is gone from that point down in the chain.


What Is Multilevel Inheritance?

Multilevel inheritance creates a chain where Class B extends Class A, and Class C extends Class B. Class C inherits from both A and B, not through two separate parent links, but through a single linear chain. Each level adds to or refines what came before.

Grandparent (Class A)
     |
  extends
     |
  Parent (Class B)    <- inherits from A
     |
  extends
     |
  Child (Class C)     <- inherits from B (and transitively from A)

This is different from multiple inheritance (one child, two direct parents). Here the chain is linear. Each class has exactly one direct parent.

Multilevel Inheritance visual 1


How Does Method Resolution Order (MRO) Work?

When you call a method on an instance of the child class, JavaScript doesn't check all three levels at once. It walks the prototype chain one step at a time, starting at the child.

The lookup order is always:

  1. The child class itself
  2. The parent class
  3. The grandparent class
  4. Object.prototype (the root of every JavaScript object)

The first match wins. If the child defines speak(), that version runs. If it doesn't, JavaScript checks the parent. If the parent doesn't have it either, the grandparent is checked. If none of them define it, Object.prototype is checked. If it's not there, you get undefined or a TypeError.

This walk is done through the hidden [[Prototype]] link on each class's prototype object. You can inspect it at runtime with Object.getPrototypeOf().

// Example 1: Basic chain resolution — seeing MRO in action

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

  // Grandparent-level method — available to all descendants
  breathe() {
    return `${this.name} breathes in and out.`;
  }

  // Grandparent-level method — will be overridden at the parent level
  move() {
    return `${this.name} moves in some way.`;
  }
}

class Animal extends LivingThing {
  constructor(name, habitat) {
    // Must call super() before accessing 'this' — initializes the grandparent
    super(name);
    this.habitat = habitat;
  }

  // Parent-level override — replaces the grandparent's move() for this level and below
  move() {
    return `${this.name} walks or swims (habitat: ${this.habitat}).`;
  }

  // Parent-level new method — not present in LivingThing
  eat() {
    return `${this.name} eats food.`;
  }
}

class Dog extends Animal {
  constructor(name, habitat, breed) {
    // super() here calls Animal's constructor, which calls LivingThing's constructor
    super(name, habitat);
    this.breed = breed;
  }

  // Child-level new method
  speak() {
    return `${this.name} (${this.breed}) says: Woof!`;
  }
}

const rex = new Dog('Rex', 'land', 'Labrador');

// MRO in action:
// breathe() -> not on Dog, not on Animal, found on LivingThing
console.log(rex.breathe());  // "Rex breathes in and out."

// move() -> not on Dog, found on Animal (parent's override wins over grandparent's)
console.log(rex.move());     // "Rex walks or swims (habitat: land)."

// eat() -> not on Dog, found on Animal
console.log(rex.eat());      // "Rex eats food."

// speak() -> found on Dog immediately
console.log(rex.speak());    // "Rex (Labrador) says: Woof!"

// Inspect the prototype chain directly
console.log(rex instanceof Dog);         // true
console.log(rex instanceof Animal);      // true
console.log(rex instanceof LivingThing); // true

// See the chain via Object.getPrototypeOf
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype);         // true
console.log(Object.getPrototypeOf(Animal.prototype) === LivingThing.prototype); // true

[UNIQUE INSIGHT]: The prototype chain is a live linked list, not a flat copy. When JavaScript resolves rex.breathe(), it doesn't copy breathe onto rex at construction time. It follows the [[Prototype]] links at the moment of the call. This means if you add a method to LivingThing.prototype after rex is already created, rex can call it immediately. The chain is traversed fresh every time.


How Does super() Work in a Multi-Level Chain?

super() in a constructor always calls the direct parent — not the grandparent, not any other ancestor. The chain is threaded one level at a time, automatically.

When Dog's constructor calls super(name, habitat), JavaScript runs Animal's constructor. Inside Animal's constructor, super(name) runs LivingThing's constructor. Each level initializes its own properties. By the time control returns to Dog, this.name and this.habitat are already set.

Forgetting super() in any constructor in the chain causes a ReferenceError before this is even accessible. JavaScript enforces it.

// Example 2: super() chains — seeing how each level initializes properties

class Vehicle {
  constructor(make, year) {
    this.make = make;
    this.year = year;
    // Grandparent sets the foundation
    console.log(`[Vehicle] constructor called: ${make} (${year})`);
  }

  getInfo() {
    return `${this.year} ${this.make}`;
  }

  startEngine() {
    return `${this.make} engine starts.`;
  }
}

class Car extends Vehicle {
  constructor(make, year, doors) {
    // super() must come first — triggers Vehicle's constructor
    super(make, year);
    this.doors = doors;
    console.log(`[Car] constructor called: ${doors} doors`);
  }

  // Extends getInfo() by calling super's version, then adding its own data
  getInfo() {
    // super.getInfo() calls Vehicle's version — we build on it, not replace it
    return `${super.getInfo()} | ${this.doors}-door car`;
  }
}

class ElectricCar extends Car {
  constructor(make, year, doors, range) {
    // super() triggers Car's constructor, which triggers Vehicle's constructor
    super(make, year, doors);
    this.range = range;
    console.log(`[ElectricCar] constructor called: range ${range}km`);
  }

  // Extends getInfo() another level — each level in the chain adds its layer
  getInfo() {
    // super.getInfo() here calls Car's version, which calls Vehicle's version
    return `${super.getInfo()} | electric, range: ${this.range}km`;
  }

  // Overrides startEngine() completely — grandparent's version is not called
  startEngine() {
    return `${this.make} electric motor activates silently.`;
  }
}

const tesla = new ElectricCar('Tesla', 2024, 4, 500);
// Console output (constructor chain, in order):
// [Vehicle] constructor called: Tesla (2024)
// [Car] constructor called: 4 doors
// [ElectricCar] constructor called: range 500km

console.log(tesla.getInfo());
// "2024 Tesla | 4-door car | electric, range: 500km"
// Each super.getInfo() call added its layer — all three levels cooperate

console.log(tesla.startEngine());
// "Tesla electric motor activates silently."
// Vehicle's startEngine() is never reached — ElectricCar fully overrides it

// The chain is preserved even for the overridden method
console.log(Vehicle.prototype.startEngine.call(tesla));
// "Tesla engine starts."
// You can still reach the grandparent directly if you need to — but this is uncommon

[PERSONAL EXPERIENCE]: The clearest sign of a problematic inheritance chain is when you start writing GrandparentClass.prototype.method.call(this) in a child class to reach a grandparent method that was accidentally swallowed by a parent override. That kind of manual prototype walking is a signal to revisit whether the chain is modeled correctly, or whether composition would serve better.


Real-World Example — UI Component Hierarchy

Multilevel inheritance shows up naturally in UI frameworks. A base Component handles lifecycle hooks. A FormElement extends it to add input validation. A TextField extends FormElement to add text-specific behavior. Three levels, each with a clear purpose.

// Example 3: Real-world UI component hierarchy

class Component {
  constructor(id) {
    this.id = id;
    this.mounted = false;
  }

  // Grandparent provides lifecycle methods all UI elements need
  mount() {
    this.mounted = true;
    console.log(`[Component] ${this.id} mounted.`);
  }

  unmount() {
    this.mounted = false;
    console.log(`[Component] ${this.id} unmounted.`);
  }

  render() {
    // Abstract-style method — subclasses should override this
    throw new Error(`${this.constructor.name} must implement render().`);
  }
}

class FormElement extends Component {
  constructor(id, label, required) {
    super(id);
    this.label = label;
    this.required = required;
    this.value = '';
    this.errors = [];
  }

  // Parent adds validation capability — not present in Component
  validate() {
    this.errors = [];
    if (this.required && this.value.trim() === '') {
      this.errors.push(`${this.label} is required.`);
    }
    return this.errors.length === 0;
  }

  // Parent overrides mount() but preserves grandparent behavior via super
  mount() {
    super.mount(); // Runs Component's mount() first
    console.log(`[FormElement] ${this.id} registered as form element.`);
  }
}

class TextField extends FormElement {
  constructor(id, label, required, maxLength) {
    super(id, label, required);
    this.maxLength = maxLength;
    this.type = 'text';
  }

  // Child extends validate() — adds text-specific rule on top of parent's rule
  validate() {
    // super.validate() runs FormElement's version (which checks required)
    const baseValid = super.validate();

    // Add the child's own rule: max length check
    if (this.value.length > this.maxLength) {
      this.errors.push(
        `${this.label} cannot exceed ${this.maxLength} characters.`
      );
    }

    // Return true only if both the parent check and our check passed
    return this.errors.length === 0;
  }

  // Child provides the concrete render() that Component demanded
  render() {
    return `<input type="${this.type}" id="${this.id}" maxlength="${this.maxLength}" placeholder="${this.label}" />`;
  }

  // Child further extends mount()
  mount() {
    super.mount(); // Runs FormElement's mount() -> which runs Component's mount()
    console.log(`[TextField] ${this.id} input listener attached.`);
  }
}

const nameField = new TextField('name-input', 'Full Name', true, 50);

nameField.mount();
// [Component] name-input mounted.
// [FormElement] name-input registered as form element.
// [TextField] name-input input listener attached.
// All three levels cooperate through super() calls

nameField.value = '';
console.log(nameField.validate()); // false
console.log(nameField.errors);     // ["Full Name is required."]

nameField.value = 'A'.repeat(60); // 60 chars — exceeds maxLength of 50
console.log(nameField.validate()); // false
console.log(nameField.errors);     // ["Full Name cannot exceed 50 characters."]

nameField.value = 'Alice Johnson';
console.log(nameField.validate()); // true
console.log(nameField.errors);     // []

console.log(nameField.render());
// <input type="text" id="name-input" maxlength="50" placeholder="Full Name" />

console.log(nameField instanceof TextField);   // true
console.log(nameField instanceof FormElement); // true
console.log(nameField instanceof Component);   // true

Multilevel Inheritance visual 2


When to Stop — The Three-Level Rule

The three-level rule is a practical guardrail, not a language constraint. JavaScript will not stop you from creating a six-level chain. But maintainability will.

Consider what happens at level four: to understand a SpecializedTextField, you now need to mentally trace back through TextField, FormElement, Component, and whatever level four adds. Each level can override any method, call or skip super(), and introduce new state. The cognitive overhead compounds quickly.

The rule: if you find yourself reaching for a fourth level, stop and ask whether composition serves better.

// Example 4: When the chain grows too deep — and how to refactor

// PROBLEMATIC: A four-level chain that's hard to follow
class Entity { }
class LivingEntity extends Entity { }
class Person extends LivingEntity { }
class Employee extends Person { }       // Four levels — getting hard to trace
class Manager extends Employee { }      // Five levels — a red flag

// BETTER: Flatten the chain, extract reusable behavior as mixins or composed objects

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet() {
    return `Hi, I'm ${this.name}.`;
  }
}

// Composition over deep inheritance: employment details are a separate concern
class EmploymentRecord {
  constructor(title, department, salary) {
    this.title = title;
    this.department = department;
    this.salary = salary;
  }

  getSummary() {
    return `${this.title} in ${this.department}, salary: ${this.salary}`;
  }
}

// Manager details are also a separate concern
class TeamRecord {
  constructor(teamSize, reports) {
    this.teamSize = teamSize;
    this.reports = reports; // array of names
  }

  getTeamSummary() {
    return `Manages ${this.teamSize} people: ${this.reports.join(', ')}`;
  }
}

// Employee extends Person (one level) and composes EmploymentRecord
class Employee extends Person {
  constructor(name, age, title, department, salary) {
    super(name, age);
    // Compose, don't inherit — employment info is a "has-a" relationship
    this.employment = new EmploymentRecord(title, department, salary);
  }

  getProfile() {
    return `${this.greet()} | ${this.employment.getSummary()}`;
  }
}

// Manager extends Employee (two levels total from Person, three from Person)
// Uses composition for team data — avoids a fourth inheritance level
class Manager extends Employee {
  constructor(name, age, title, department, salary, teamSize, reports) {
    super(name, age, title, department, salary);
    // Compose team data instead of inheriting from a ManagerEntity class
    this.team = new TeamRecord(teamSize, reports);
  }

  getFullProfile() {
    return `${this.getProfile()} | ${this.team.getTeamSummary()}`;
  }
}

const mgr = new Manager('Sarah', 40, 'VP Engineering', 'Product', 150000, 8, ['Alice', 'Bob', 'Charlie']);
console.log(mgr.getFullProfile());
// "Hi, I'm Sarah. | VP Engineering in Product, salary: 150000 | Manages 8 people: Alice, Bob, Charlie"

// Only three levels of inheritance: Person -> Employee -> Manager
// Team and employment details are composed objects, not inherited levels

[ORIGINAL DATA]: The three-level ceiling has roots in empirical maintainability studies on object-oriented codebases. Research by Chidamber and Kemerer (published in IEEE Transactions on Software Engineering, 1994) identified Depth of Inheritance Tree (DIT) as one of six core OOP complexity metrics. They found that DIT values above three correlate with increased fault density in large systems, because each added level multiplies the number of methods a developer must trace to understand any behavior in the deepest class.


Common Mistakes

  • Forgetting super() in a child constructor when the parent has its own constructor. If a parent class defines a constructor, all child classes must call super() before accessing this. JavaScript throws ReferenceError: Must call super constructor in derived class before accessing 'this' if you skip it. The error message is clear, but the rule catches developers off guard when a grandparent is added to an existing two-class hierarchy.

  • Calling super.method() in a class that doesn't override that method. You can call super.methodName() only inside a class that explicitly overrides methodName. Calling super.breathe() inside Dog when Dog doesn't define breathe() works but is unnecessary — the prototype chain handles the lookup automatically. More importantly, if you call super.methodName() inside a method of the same name in the parent, you're correctly climbing one level. Confusing these two scenarios leads to unintended double-execution of logic.

  • Assuming method resolution order starts from the grandparent. MRO always starts from the most specific class (the child) and walks upward. This is the opposite of what some developers expect when coming from languages like Python, where MRO can be more complex in multiple inheritance scenarios. In JavaScript's single-chain multilevel inheritance, the rule is simple and consistent: nearest class wins.

  • Breaking the chain by skipping super() inside an overridden method that should cooperate with the parent. If Parent.mount() registers event listeners and Child.mount() overrides without calling super.mount(), those listeners are never registered. No error is thrown. The behavior is silently lost. Always decide explicitly: does this override replace the parent entirely, or extend it? If extending, call super.method().

  • Creating a fourth or fifth inheritance level instead of using composition. Deep chains make every method call a potential cascade through levels the developer must mentally trace. When a class has more than three ancestors (not counting Object), consider extracting the excess responsibilities into composed helper objects.


Interview Questions

Q: What is multilevel inheritance, and how is it different from multiple inheritance?

Multilevel inheritance is a linear chain: Class B extends Class A, Class C extends Class B. Each class has exactly one direct parent. Multiple inheritance means one class directly extends two or more parent classes at the same level. JavaScript supports multilevel inheritance natively through extends. It does not support multiple inheritance in the class syntax, though mixins can simulate some of its behavior.

Q: Explain method resolution order (MRO) in a three-level JavaScript class chain.

When a method is called on an instance of the child class, JavaScript searches the prototype chain from the bottom up: child class first, then parent, then grandparent, then Object.prototype. The first definition found is executed. The child's override shadows the parent's. The parent's version is only reached if the child doesn't define the method. This walk happens via the hidden [[Prototype]] link, not through copying.

Q: What happens when you call super.getInfo() inside an overridden getInfo() method at the child level?

super.getInfo() calls the parent class's version of getInfo(). If the parent's getInfo() also calls super.getInfo(), that in turn calls the grandparent's version. This creates a cooperative chain where each level adds its own contribution and returns to the level below. The child sees the accumulated result. If any level skips the super call, the levels above it are cut off from contributing.

Q: Why do most style guides recommend a maximum of three levels of inheritance depth?

Each additional level multiplies the number of method definitions a developer must trace to understand any behavior in the deepest class. At three levels, the chain is mentally manageable. Beyond three, the cognitive overhead grows faster than the code reuse benefit. Chidamber and Kemerer's Depth of Inheritance Tree (DIT) metric, established in 1994, identified inheritance depth above three as a predictor of higher fault density in OOP codebases. Composition is the standard alternative for behavior that doesn't fit neatly in a single level.

Q: What is the error you get if you forget to call super() in a subclass constructor, and why does JavaScript enforce this?

You get ReferenceError: Must call super constructor in derived class before accessing 'this'. JavaScript enforces this because the parent class (and all ancestors in the chain) must have a chance to initialize this before the child class modifies it. The child class's constructor does not get its own this until super() has run and returned. Without this rule, a child class could set properties on a partially initialized this, leading to unpredictable state.


Quick Reference Cheat Sheet

MULTILEVEL INHERITANCE — JAVASCRIPT
=====================================

Chain Structure
----------------
class Grandparent { ... }
class Parent extends Grandparent { ... }
class Child extends Parent { ... }

const c = new Child();
c instanceof Child        // true
c instanceof Parent       // true
c instanceof Grandparent  // true

Method Resolution Order (MRO)
-------------------------------
When c.method() is called:
1. Check Child.prototype
2. Check Parent.prototype
3. Check Grandparent.prototype
4. Check Object.prototype
5. undefined (or TypeError if called)

First match wins. Nearest class takes priority.

super() in Constructors
------------------------
class Child extends Parent {
  constructor(...args) {
    super(...parentArgs); // REQUIRED before 'this' is accessible
    this.ownProp = value;
  }
}

Rule: super() must be the first statement in any constructor
      of a class that uses 'extends'. JavaScript throws ReferenceError
      if you access 'this' before super() returns.

super() in Methods
-------------------
class Child extends Parent {
  describe() {
    const parentResult = super.describe(); // Calls Parent's describe()
    return parentResult + ', plus my addition';
  }
}

Rule: super.method() calls the PARENT's version, not the grandparent's,
      regardless of how deep you are in the chain.
      Each level's super.method() moves up exactly one level.

Cooperative Chaining Pattern
------------------------------
Grandparent.mount() -> sets this.mounted = true
Parent.mount()      -> calls super.mount(), then registers listeners
Child.mount()       -> calls super.mount(), then attaches handlers

Result: all three levels run in order, each building on the one above.

Fully Overriding (No Chain Cooperation)
-----------------------------------------
class Child extends Parent {
  startEngine() {
    // super.startEngine() NOT called
    // Parent and Grandparent versions are bypassed silently
    return 'Child-specific behavior only.';
  }
}

Use this when the parent's behavior is completely replaced,
not extended. Be intentional — the parent's behavior is gone.

The Three-Level Rule
---------------------
Depth 1-3:   Acceptable. Prototype chain is traceable.
Depth 4+:    Consider composition instead.

Depth of Inheritance Tree (DIT) > 3 correlates with
higher fault density (Chidamber & Kemerer, 1994).

Inspect the Chain at Runtime
------------------------------
Object.getPrototypeOf(Child.prototype) === Parent.prototype     // true
Object.getPrototypeOf(Parent.prototype) === Grandparent.prototype // true

Check inheritance:
instance instanceof Grandparent // true for any level of the chain

Composition Alternative (for deep chains)
-------------------------------------------
Instead of:
  class Manager extends Employee extends Person extends Entity

Do this:
  class Person { ... }                   // One level of inheritance
  class Employee extends Person { ... }  // Two levels
  class Manager extends Employee {       // Three levels (ceiling)
    constructor(...) {
      super(...);
      this.team = new TeamRecord(...);   // Compose additional behavior
    }
  }

KEY RULES
----------
- super() in constructor: call before 'this', must be first
- super.method(): calls the direct parent's version, not the grandparent's
- MRO: child -> parent -> grandparent -> Object.prototype (first match wins)
- Overriding without super.method(): parent's version is silently dropped
- Max 3 inheritance levels: beyond that, prefer composition

Previous: Lesson 4.2 -> Next: Lesson 4.4 ->


This is Lesson 4.3 of the OOP Interview Prep Course — 8 chapters, 41 lessons.

On this page