Learn JS Series (#39) - Class Inheritance: extends, super, and the Prototype Chain Again
Learn JS Series (#39) - Class Inheritance: extends, super, and the Prototype Chain Again
What will I learn
- You will learn how
extendssets up inheritance between classes; - what
super(...)does in a subclass constructor, and why it is mandatory beforethis; - how
super.method()calls the parent's version of a method; - how
extendsmaps 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 (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
- Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
- Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
- Learn JS Series (#21) - Destructuring Parameters: Named Arguments the JS Way
- Learn JS Series (#22) - The this Keyword: Five Rules That Explain Every Case
- Learn JS Series (#23) - call, apply, and bind: Controlling this Explicitly
- Learn JS Series (#24) - Recursion: Base Cases, the Call Stack, and Stack Overflows
- Learn JS Series (#25) - IIFEs and the Module Pattern (the Pre-2015 Way to Get Privacy)
- Learn JS Series (#26) - Currying and Partial Application
- Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
- Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code
- Learn JS Series (#29) - Memoization: Trading Memory for Speed with Closures
- Learn JS Series (#30) - Mini Project: A Small Functional Utility Library
- Learn JS Series (#31) - Object Literals, Shorthand, and Computed Property Names
- Learn JS Series (#32) - Property Descriptors: writable, enumerable, configurable
- Learn JS Series (#33) - Getters and Setters: Computed Properties That Look Like Data
- Learn JS Series (#34) - The Prototype: JavaScript's Actual Inheritance Mechanism
- Learn JS Series (#35) - The Prototype Chain: How Property Lookup Really Works
- Learn JS Series (#36) - Object.create and Pure Prototypal Inheritance
- Learn JS Series (#37) - Constructor Functions and the new Operator, Step by Step
- Learn JS Series (#38) - The class Syntax: Sugar Over Prototypes, and What It Hides
- Learn JS Series (#39) - Class Inheritance: extends, super, and the Prototype Chain Again (this post)
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
- Create a class
Shapewith aconstructor(name)and adescribe()returning"a shape called <name>". Then createCircle extends Shapewhose constructor takes(name, radius), callssuper(name), and adds anarea()method. Make a circle and call bothdescribe()andarea(). - In your
Circle, overridedescribe()so it callssuper.describe()and appends" with area X"(using yourarea()). Show that both the parent's text and the child's addition appear, and explain in one sentence whatsuper.describe()does withthis. - Extend the built-in
Errorclass to make aNotFoundErrorwith an extraresourceproperty. Throw one inside a function, catch it, and print itsmessage, itsresource, and confirmerr instanceof Erroristrue. Explain in one sentence why thesuper(message)call was necessary.
So what did we actually cover?
extendsmakes 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 tosuper.- A subclass constructor MUST call
super(...)before usingthis; in a derived class the parent constructor is what creates and initializes the object, sothisdoes not exist untilsuperruns. super.method()calls the parent's version of an overridden method with the correctthis-- the clean form of episode 36's hand-written "call the original" pattern.extendsis prototype chaining: it linksSub.prototypetoSuper.prototype(and theSubconstructor to theSuperconstructor), building exactly the multi-level chain you made by hand.- You can extend built-ins; custom error classes extending
Errorare 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.