This content got low rating by people.

Learn JS Series (#39) - Class Inheritance: extends, super, and the Prototype Chain Again

Words
2799
Reading
13 min
Listen
Play
4d

Learn JS Series (#39) - Class Inheritance: extends, super, and the Prototype Chain Again

js-banner.png

What will I learn

  • You will learn how extends sets up inheritance between classes;
  • what super(...) does in a subclass constructor, and why it is mandatory before this;
  • how super.method() calls the parent's version of a method;
  • how extends maps directly to the prototype chaining you did by hand in episode 36;
  • when inheritance is the right tool and when composition serves you better.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-38 read, especially prototypes, Object.create, and the class syntax.

Difficulty

  • Intermediate

Curriculum (of the Learn JS Series):

Learn JS Series (#39) - Class Inheritance: extends, super, and the Prototype Chain Again

Last episode we lifted the lid on the class keyword and found, unsurprisingly, the same constructor-and-prototype machinery we had been wiring by hand for four episodes. A class IS a function, its methods live on the prototype, and every === check we ran came back exactly as it did for constructor functions. I promised, right at the end, that the real payoff of the class syntax was still coming -- and here it is. Today we chain classes together with inheritance: extends to make one class build on another, and super to reach up to the parent. Having said that, I want you to keep one thing in the back of your mind the entire time: none of this is new machinery either. It is, once again, the multi-level prototype chain you assembled by hand in episode 36, dressed in a much nicer suit. So when super starts to feel magical, remember we already did it the hard way -- and it wasn't magic then. ;-)

Solutions to Episode 38 Exercises

Exercise 1 - constructor function as a class:

class Circle {
  constructor(radius) { this.radius = radius; }
  area() { return Math.PI * this.radius ** 2; }
}
const a = new Circle(2), b = new Circle(3);
console.log(typeof Circle);               // "function"
console.log(a.area === b.area);           // true - shared method
console.log(Object.hasOwn(a, "radius"), Object.hasOwn(a, "area")); // true false

The insight: the class behaves identically to a constructor function -- radius is own (per-instance), area is shared on the prototype, and typeof Circle is still "function". Sugar, not a new species of thing.

Exercise 2 - a rectangle with getters:

class Rectangle {
  constructor(width, height) { this.width = width; this.height = height; }
  get area() { return this.width * this.height; }
  get isSquare() { return this.width === this.height; }
}
const r = new Rectangle(4, 4);
console.log(r.area, r.isSquare); // 16 true

The insight: getters are read like data properties -- no parentheses -- and they live on the prototype as accessor properties, exactly as they would if you had reached for Object.defineProperty.

Exercise 3 - real class differences:

class User { constructor(name) { this.name = name; } }
// User("x");   // TypeError: Class constructor User cannot be invoked without 'new'
// new Early(); // ReferenceError: Cannot access 'Early' before initialization
// class Early {}

The insight: classes cannot be called without new, and they are not hoisted (they sit in the Temporal Dead Zone from episode 10). Both are improvements that turn silent constructor-function footguns into loud, immediate errors.

Now the moment the last four episodes were building toward -- let's chain classes together with inheritance.

extends: one class inheriting from another

The extends keyword makes one class inherit from another. The child class (the subclass) gets all the methods of the parent class (the superclass), and can add its own methods or override the parent's. This is nothing more than the class syntax for the multi-level prototype chains you built by hand in episode 36 -- the same idea, far less typing:

class Animal {
  constructor(name) { this.name = name; }
  eat() { return `${this.name} is eating`; }
  describe() { return `${this.name} is an animal`; }
}

class Dog extends Animal {
  fetch() { return `${this.name} fetches the ball`; }
}

const rex = new Dog("Rex");
console.log(rex.eat());      // "Rex is eating" - inherited from Animal
console.log(rex.describe()); // "Rex is an animal" - inherited from Animal
console.log(rex.fetch());    // "Rex fetches the ball" - own method

Dog extends Animal means every Dog is also an Animal: it inherits eat, describe, and the constructor, while adding fetch of its own. We wrote no prototype code at all -- extends set up the chain for us. And note something easy to miss: we did not even write a constructor for Dog. When you omit the subclass constructor entirely, JavaScript synthesizes one for you that simply forwards all its arguments to the parent (roughly constructor(...args) { super(...args); }). That is why new Dog("Rex") still managed to set this.name -- the inherited constructor ran Animal's constructor with "Rex".

Overriding is just as easy: define a method in the subclass with the same name as one in the parent, and the subclass version wins for Dog instances, because it is found first walking up the chain. We will use that in a moment, but first the constructor rule that trips up every newcomer.

super in the constructor

Usually a subclass needs its own constructor, to accept extra data the parent knows nothing about. But there is a strict rule, and the language enforces it without mercy: a subclass constructor must call super(...) before it can use this. super(...) invokes the parent class's constructor, and in a derived class it is the parent's constructor that actually creates and initializes the object. Until you call super, this genuinely does not exist yet -- touching it is a ReferenceError, not a warning:

class Animal {
  constructor(name) { this.name = name; }
  describe() { return `${this.name} is a ${this.type}`; }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);        // MUST call super before using 'this'
    this.type = "dog";  // now 'this' is available
    this.breed = breed;
  }
}

const rex = new Dog("Rex", "labrador");
console.log(rex.describe()); // "Rex is a dog"
console.log(rex.breed);      // "labrador"

super(name) runs Animal's constructor with name, setting up this.name. Only after that can Dog's constructor add this.type and this.breed. Forget the super() call and reference this, and you get a ReferenceError the very moment you touch it -- the engine refuses to let you work with a half-built object. This maps directly to episode 36's init pattern, where the parent initialized its part first and the child then added its own: same ordering, now mandated by the language in stead of left to your discipline.

A small but important detail: the order of arguments is yours to design. Dog's constructor takes (name, breed), forwards only name up to Animal, and keeps breed for itself. The parent does not need to know the child exists; the child decides what to pass upward. That clean separation of responsibilities -- parent initializes the shared part, child initializes the extra part -- is exactly what good inheritance looks like.

super.method(): calling the parent's version

The other use of super is calling the parent's version of a method you have overridden. When a subclass overrides a method but wants to extend rather than fully replace it, super.method() reaches up to the parent's implementation and runs it, still with this bound to your instance. This is precisely the "override but call the original" pattern from episode 36, now with clean syntax instead of a hand-written .call(this):

class Animal {
  describe() { return `${this.name} is an animal`; }
}

class Dog extends Animal {
  constructor(name) { super(); this.name = name; }
  describe() {
    // extend the parent's method rather than replace it
    return `${super.describe()}, specifically a dog`;
  }
}

const rex = new Dog("Rex");
console.log(rex.describe()); // "Rex is an animal, specifically a dog"

super.describe() calls Animal's describe with this STILL bound to the Dog instance (so this.name resolves to "Rex"), then Dog appends its own bit to the result. Compare this to the episode 36 version, where we wrote base.greet.call(this) by hand to borrow the parent method while keeping our own this -- super is the elegant sugar for exactly that dance. It finds the parent's method on the prototype chain and calls it with the correct this, automatically, every time.

This "wrap the parent, add a little" pattern is one of the genuinely good uses of inheritance. A LoggingArray that overrides push to log and then calls super.push(...), a specialized component that renders the base output and adds a decoration -- these are clean, shallow, and readable. The trouble only starts when the hierarchy grows tall, which we will get to at the end.

extends is prototype chaining

Let's make the connection explicit, because it ties the whole phase together and it is the single most useful mental model you can carry out of this episode. extends sets up TWO prototype links behind the scenes: instances of Dog link to Dog.prototype, and Dog.prototype in turn links to Animal.prototype. That is precisely the multi-level chain you built manually in episode 36 -- rex -> Dog.prototype -> Animal.prototype -> Object.prototype -> null:

class Animal { eat() {} }
class Dog extends Animal { fetch() {} }
const rex = new Dog();

console.log(Object.getPrototypeOf(rex) === Dog.prototype);              // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true - the chain link
console.log(rex instanceof Dog);    // true
console.log(rex instanceof Animal); // true - it is both

Object.getPrototypeOf(Dog.prototype) === Animal.prototype is the money line: it PROVES extends linked the prototypes into a chain, nothing more mystical than that. A lookup for eat on rex climbs rex -> Dog.prototype (not found) -> Animal.prototype (found!) and runs it -- the exact same walk we traced by hand in episode 35. And rex instanceof Animal being true reflects that Animal.prototype sits somewhere on rex's chain (episode 42 digs into how instanceof really works). So there is no separate "inheritance system" in JavaScript -- there is only the prototype chain, and extends is a friendly spelling for wiring it up correctly.

There is a second, subtler link that extends also sets up, and it is the one that makes super work in constructors. Not only does Dog.prototype point at Animal.prototype, but the Dog constructor itself points at the Animal constructor (Object.getPrototypeOf(Dog) === Animal). That is how super(...) knows which parent constructor to run, and how a subclass inherits the parent's static members too (which we look at next episode). Two links, one keyword. Do it by hand and you would need both Object.create for the prototype link AND a manual reference for the constructor link -- extends handles the pair for you.

Extending built-ins

Because built-in types like Array and Error are themselves classes (with prototypes), you can extend them to create specialized versions. Extending Error to make custom error types is a genuinely common, useful case that you will reach for in real code -- so let's do that one properly:

class ValidationError extends Error {
  constructor(message, field) {
    super(message);              // set up the Error's message + stack
    this.name = "ValidationError";
    this.field = field;
  }
}

const err = new ValidationError("must be a number", "age");
console.log(err.message);           // "must be a number" - from Error
console.log(err.field);             // "age" - our addition
console.log(err instanceof Error);  // true - it is a REAL Error
console.log(err instanceof ValidationError); // true - and specifically ours

ValidationError inherits all of Error's behaviour (the message property, the stack trace, the way it prints in a console) via super(message), and adds a field of its own. Because it truly IS an instanceof Error, it works everywhere errors are expected -- throw, catch, promise rejections, all of it. And because it is ALSO an instanceof ValidationError, calling code can tell YOUR errors apart from generic ones and handle them specifically:

function parseAge(input) {
  const n = Number(input);
  if (Number.isNaN(n)) throw new ValidationError("must be a number", "age");
  return n;
}

try {
  parseAge("oops");
} catch (e) {
  if (e instanceof ValidationError) {
    console.log(`bad field ${e.field}: ${e.message}`); // "bad field age: must be a number"
  } else {
    throw e; // not ours -- re-throw
  }
}

That if (e instanceof ValidationError) check is why custom error classes matter: they let you catch the errors you understand and re-throw the ones you do not, in stead of swallowing everything in one blind catch. Custom error classes extending Error are, without exaggeration, one of the most practical everyday uses of class inheritance.

A quick look sideways: inheritance in Python

Quite some of you arrived here from the Learn Python Series, so a short comparison sharpens what is really going on under JavaScript's extends. Python spells the same idea with parentheses in the class header and an explicit super() call:

class Animal:
    def __init__(self, name):
        self.name = name
    def describe(self):
        return f"{self.name} is an animal"

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)      # like super(name) in JS
        self.breed = breed
    def describe(self):
        return f"{super().describe()}, specifically a dog"  # extend the parent

rex = Dog("Rex", "labrador")
print(rex.describe())               # "Rex is an animal, specifically a dog"

It reads almost line-for-line like the JavaScript. Dog(Animal) is Dog extends Animal, super().__init__(name) is super(name), and super().describe() is super.describe(). The crucial difference is philosophical, and it is the same point I made about classes last episode: in Python, inheritance walks a formal chain of CLASS objects (the method resolution order, or MRO), while in JavaScript it walks the chain of PROTOTYPE objects. They LOOK identical and behave nearly identically for everyday code, but the mechanism differs -- and that difference is why JavaScript can do things like return a fresh class from a factory (a closure over a class), which classical languages handle very differently. Seeing both side by side makes the sugar visible: the same problem -- give a child the parent's behaviour plus a little extra -- solved with different underlying machinery. If you know one, you can read the other; just do not assume the plumbing is the same.

Inheritance versus composition

A word of caution to close, because this is exactly where people misuse classes. Inheritance models an "is-a" relationship (a Dog is an Animal, a ValidationError is an Error), and it works beautifully for genuine, shallow hierarchies. But it is dangerously easy to overuse. Deep inheritance trees -- Animal -> Mammal -> Pet -> Dog -> Puppy -- become rigid and hard to change, because a subclass is tightly coupled to EVERY ancestor above it. Change a method three levels up and you can break children you forgot existed. This is the classic "fragile base class" problem, and it is real.

When you find yourself reaching for four or five levels of extends, or forcing an "is-a" that is really a "has-a", composition is usually the better tool: build objects that contain and use other objects, rather than inheriting from them. Instead of a Duck that extends Bird that extends FlyingThing that extends Animal, give your Duck a swimmer, a flyer, and a quacker -- small capabilities it holds and delegates to:

const canSwim = (name) => ({ swim: () => `${name} paddles across the pond` });
const canFly  = (name) => ({ fly:  () => `${name} takes off` });

function makeDuck(name) {
  return { name, ...canSwim(name), ...canFly(name) }; // composed from small parts
}

const donald = makeDuck("Donald");
console.log(donald.swim()); // "Donald paddles across the pond"
console.log(donald.fly());  // "Donald takes off"

No inheritance tree at all -- just a duck assembled from independent capabilities, each of which you could test and reuse on its own. Mixins (episode 43) formalize this "share behaviour without a single inheritance line" idea, and Phase 10's functional patterns lean on it heavily. JavaScript's flexible object model makes composition especially natural, which is exactly why the community tends to favour it over towering class hierarchies.

So here is the rule of thumb I would give you: reach for inheritance when the "is-a" is real AND the hierarchy is shallow (one, maybe two levels); reach for composition when you are mostly combining capabilities, or when the tree starts to grow tall. Used with that judgment, extends and super are clean, readable, and powerful. Used to build a five-storey inheritance tower, they quietly fight the grain of the language -- and the language always wins that fight eventually.

Try it yourself

  1. Create a class Shape with a constructor(name) and a describe() returning "a shape called <name>". Then create Circle extends Shape whose constructor takes (name, radius), calls super(name), and adds an area() method. Make a circle and call both describe() and area().
  2. In your Circle, override describe() so it calls super.describe() and appends " with area X" (using your area()). Show that both the parent's text and the child's addition appear, and explain in one sentence what super.describe() does with this.
  3. Extend the built-in Error class to make a NotFoundError with an extra resource property. Throw one inside a function, catch it, and print its message, its resource, and confirm err instanceof Error is true. Explain in one sentence why the super(message) call was necessary.

So what did we actually cover?

  • extends makes one class inherit from another; the subclass gets all the superclass's methods and can add new ones or override existing ones. Omit the subclass constructor and JavaScript synthesizes one that forwards to super.
  • A subclass constructor MUST call super(...) before using this; in a derived class the parent constructor is what creates and initializes the object, so this does not exist until super runs.
  • super.method() calls the parent's version of an overridden method with the correct this -- the clean form of episode 36's hand-written "call the original" pattern.
  • extends is prototype chaining: it links Sub.prototype to Super.prototype (and the Sub constructor to the Super constructor), building exactly the multi-level chain you made by hand.
  • You can extend built-ins; custom error classes extending Error are a common, practical use, letting calling code catch YOUR errors specifically and re-throw the rest.
  • Use inheritance for genuine, shallow "is-a" relationships; prefer composition when you are combining capabilities or when a hierarchy threatens to grow deep.

Next episode we look at class members that belong to the class itself rather than to any instance: static methods, static properties, and static initialization blocks -- the "class-level" side of the story we have only hinted at so far.

That's it for today, see you in the next episode.

scipio@scipio

Learn JS Series (#39) - Class Inheritance: extends, super, and the ... | Ecency