JavaScript Inheritance

Inheritance lets one object or class reuse and extend the behavior of another, so you don’t repeat the same properties and methods everywhere. In JavaScript, inheritance is not a copy-paste of code between objects — it’s a live link called the prototype chain that the engine walks every time you access a property. Understanding that chain is the key to understanding almost everything else in JavaScript OOP, including how the modern class/extends/super syntax actually works underneath.

Overview / How It Works

Every object in JavaScript has an internal, hidden link to another object (or to null) called its prototype. This internal link is officially named [[Prototype]], and you can read or write it through Object.getPrototypeOf(obj) and Object.setPrototypeOf(obj, proto), or informally through the non-standard-but-widely-supported __proto__ accessor. When you access a property on an object, the JavaScript engine first looks for an own property directly on that object. If it isn’t found, the engine follows the [[Prototype]] link to the next object up the chain, and keeps climbing until it either finds the property or reaches null, at which point it returns undefined. This chain of linked objects is called the prototype chain, and it is the entire mechanism behind inheritance in JavaScript — there is no separate “class” system at the bytecode level, even when you write class syntax.

Before ES6, inheritance was built manually with constructor functions: every function has a .prototype property (an ordinary object), and objects created with new SomeFunction() get their internal [[Prototype]] set to SomeFunction.prototype. To inherit from another constructor, developers set Child.prototype = Object.create(Parent.prototype) and called Parent.call(this, ...) inside the child constructor to reuse the parent’s setup logic. ES6 introduced class, extends, and super as syntax sugar over exactly this mechanism. A class declaration is still a function under the hood, its methods still live on .prototype, and extends still wires up the same [[Prototype]] links — but the syntax is clearer, enforces calling the parent constructor before using this, and adds features like super.method() to call an overridden parent method.

It’s important to separate two different links that both matter for inheritance. First, every instance has a [[Prototype]] pointing to its constructor’s .prototype object — this is how instances share methods. Second, when you use class Child extends Parent, the constructor functions themselves are also linked: Object.getPrototypeOf(Child) === Parent. This second link is what makes static methods and properties inherited too — Child.someStaticMethod() can find a method defined as static on Parent even though no instance is involved.

Syntax

class Child extends Parent {
  constructor(args) {
    super(parentArgs);   // must run before using `this`
    this.ownProp = value;
  }
  method() {
    super.method();       // calls Parent's version of method()
  }
}
Piece Meaning
extends Declares that Child.prototype‘s internal [[Prototype]] is Parent.prototype, and links the constructors for static inheritance.
super(...) Calls the parent class’s constructor. Required before any use of this in a derived class constructor.
super.method() Calls the parent’s version of an overridden method, from inside a method.
Object.create(proto) The lower-level, pre-class way to create an object with a specific prototype — still used for pure prototypal inheritance.
Object.getPrototypeOf(obj) Reads an object’s [[Prototype]] link.
instanceof Checks whether a constructor’s .prototype appears anywhere in an object’s prototype chain.

Examples

Example 1: Class-based inheritance with extends and super

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    console.log(`${this.name} makes a sound.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }
  speak() {
    super.speak();
    console.log(`${this.name} barks.`);
  }
}

const d = new Dog('Rex', 'Labrador');
d.speak();
console.log(d instanceof Animal);
console.log(d instanceof Dog);

Output:

Rex makes a sound.
Rex barks.
true
true

Dog extends Animal, so Dog.prototype‘s [[Prototype]] is Animal.prototype. Calling super(name) runs Animal‘s constructor with this already bound to the new Dog instance, setting this.name. Inside the overridden speak(), super.speak() explicitly calls Animal.prototype.speak, then the method adds its own behavior. Because the prototype chain includes both Dog.prototype and Animal.prototype, both instanceof checks succeed.

Example 2: Pure prototypal inheritance with constructor functions

function Vehicle(wheels) {
  this.wheels = wheels;
}

Vehicle.prototype.describe = function () {
  return `This vehicle has ${this.wheels} wheels.`;
};

function Car(brand) {
  Vehicle.call(this, 4);
  this.brand = brand;
}

Car.prototype = Object.create(Vehicle.prototype);
Car.prototype.constructor = Car;

Car.prototype.describe = function () {
  const base = Vehicle.prototype.describe.call(this);
  return `${base} It is a ${this.brand}.`;
};

const c = new Car('Toyota');
console.log(c.describe());
console.log(Object.getPrototypeOf(c) === Car.prototype);

Output:

This vehicle has 4 wheels. It is a Toyota.
true

This is exactly what class/extends does behind the scenes. Vehicle.call(this, 4) borrows the parent constructor’s logic (the equivalent of super(4)). Object.create(Vehicle.prototype) builds a fresh object whose [[Prototype]] is Vehicle.prototype and assigns it as Car.prototype, wiring up the chain. Resetting Car.prototype.constructor = Car is necessary because replacing Car.prototype wholesale also replaced its default constructor property — an easy step to forget when doing this manually.

Example 3: Overriding getters and inheriting static members

class Shape {
  static count = 0;
  constructor(name) {
    this.name = name;
    Shape.count++;
  }
  get label() {
    return `Shape: ${this.name}`;
  }
  static describe() {
    return `${this.count} shapes created`;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super('circle');
    this.radius = radius;
  }
  get area() {
    return Math.PI * this.radius ** 2;
  }
  get label() {
    return `${super.label} (radius ${this.radius})`;
  }
}

const circ = new Circle(2);
console.log(circ.label);
console.log(circ.area.toFixed(2));
console.log(Circle.describe());

Output:

Shape: circle (radius 2)
12.57
1 shapes created

The label getter is overridden in Circle, but it calls super.label to reuse Shape‘s version rather than duplicating the string logic. Circle.describe() is never defined on Circle itself; because classes also link their constructors (Object.getPrototypeOf(Circle) === Shape), the static method — and the static count field it reads through this — is found by walking up to Shape.

Under the Hood: Step by Step

When you write d.speak() from Example 1, the engine performs roughly these steps:

  • Look for an own property called speak directly on the object d. It isn’t there — d only has name and breed as own properties.
  • Follow d‘s [[Prototype]] link to Dog.prototype. Look for speak there. It is found, because the class body’s speak() {...} was installed on Dog.prototype when the class was defined.
  • Call that function with this bound to d.
  • Inside it, super.speak() does a different lookup: it starts searching not from d, but from the prototype of Dog.prototype — i.e. Animal.prototype — using a special internal binding called [[HomeObject]] that every method created inside a class body carries. This is why super works correctly even though this is still the Dog instance.
  • If neither Dog.prototype nor Animal.prototype had speak, the engine would keep climbing to Object.prototype, and finally to null, returning undefined for a property lookup or throwing a TypeError for a call.

This walk happens on every single property access, every time — prototypes are not “copied” onto instances, so if you later add a method to Animal.prototype, every existing Dog instance instantly gains it too, because the lookup happens live at access time.

Common Mistakes

1. Using this before calling super(). In a derived class constructor, JavaScript requires super() to run first, because the parent constructor is responsible for actually creating the this binding for the instance. Code like class Cat extends Animal { constructor(name) { this.name = name; super(name); } } throws a ReferenceError: Must call super constructor before accessing 'this'. The fix is simply to call super(...) as the first statement in the constructor before touching this in any way.

2. Defining methods inside the constructor instead of on the prototype. This wastes memory and breaks the shared-behavior benefit of prototypes, since every instance gets its own separate copy of the function.

class Counter {
  constructor() {
    this.count = 0;
    this.increment = function () {
      this.count++;
    };
  }
}

const a = new Counter();
const b = new Counter();
console.log(a.increment === b.increment);

Output:

false

Defining the method normally inside the class body puts it on the shared prototype instead:

class Counter {
  constructor() {
    this.count = 0;
  }
  increment() {
    this.count++;
  }
}

const a = new Counter();
const b = new Counter();
console.log(a.increment === b.increment);

Output:

true

3. Putting mutable objects or arrays directly on a shared prototype. Because all instances see the same prototype object through the chain, mutating a reference-type property on the prototype affects every instance at once.

function Team() {}
Team.prototype.members = [];

const teamA = new Team();
const teamB = new Team();

teamA.members.push('Alice');
console.log(teamB.members);

Output:

[ 'Alice' ]

Neither instance has its own members property, so teamA.members and teamB.members both resolve to the exact same array on Team.prototype. The fix is to assign such properties inside the constructor (this.members = []) so each instance gets its own independent array as an own property.

Best Practices

  • Prefer class/extends/super syntax over manual prototype wiring for readability — it compiles down to the same mechanism but is far less error-prone.
  • Always call super(...) as the very first line of a derived constructor, before referencing this.
  • Keep inheritance hierarchies shallow. Deep chains (more than 2-3 levels) make property lookups slower and code harder to reason about — favor composition (“has-a”) over inheritance (“is-a”) when a relationship doesn’t clearly model a true subtype.
  • Put instance-specific mutable state (arrays, objects, counters) in the constructor as own properties, not on the shared prototype.
  • Use super.method() to extend, not blindly replace, inherited behavior when overriding — it keeps the parent’s logic in one place.
  • Use instanceof or duck-typing (checking for a method’s existence) rather than manually inspecting [[Prototype]] chains in application code.
  • Avoid mutating built-in prototypes like Array.prototype or Object.prototype — it’s a classic source of hard-to-debug conflicts in larger codebases.

Practice Exercises

Exercise 1: Create a base class Employee with a constructor that takes name and salary, and a method describe() that logs " earns ". Create a subclass Manager that adds a teamSize property and overrides describe() to call the parent’s version with super.describe() and then also log the team size.

Exercise 2: Using plain constructor functions and Object.create (no class keyword), build a Shape “parent” with an area() method returning 0, and a Square “child” whose own area() overrides it to return side * side. Verify with instanceof-style checks using Object.getPrototypeOf.

Exercise 3: Predict the output before running: define a class A with a static property version = 1 and a class B extends A with no static members of its own. What does B.version log, and why does that work without B redeclaring it?

Summary

  • Inheritance in JavaScript is implemented through the prototype chain — a live linked list of objects connected via the internal [[Prototype]] slot.
  • Property lookups walk up the chain from the instance to its prototype, and further up, until found or null is reached.
  • class/extends/super is syntax sugar over the same mechanism used by constructor functions and Object.create.
  • super() must be called before this is used in a derived constructor; super.method() calls the parent’s version of an overridden method.
  • Static members are inherited too, because extends also links the constructor functions themselves, not just their .prototype objects.
  • Keep instance-owned mutable state off shared prototypes to avoid accidental cross-instance mutation bugs.