Class inheritance
Inheritance lets one class build on another. You take a class that already does something useful, and you make a new class that keeps all of that behavior while adding or changing a few things. No copy-paste, no duplication — the child class points back at the parent and borrows whatever it doesn’t define itself.
That “points back at” is the whole story, and by the end of this article you’ll see exactly how the arrow is wired.
The “extends” keyword
Start with a class Vehicle:
class Vehicle {
constructor(name) {
this.speed = 0;
this.name = name;
}
accelerate(speed) {
this.speed = speed;
alert(`${this.name} accelerates to ${this.speed}.`);
}
halt() {
this.speed = 0;
alert(`${this.name} comes to a halt.`);
}
}
let vehicle = new Vehicle("My vehicle");
A vehicle object and the Vehicle class relate to each other like this: the object stores the data (name, speed), and the class stores the shared methods on Vehicle.prototype.
accelerate: function
halt: function
speed: 0
Now suppose you want a class Ebike. Ebikes are vehicles, so Ebike should sit on top of Vehicle and reuse everything a generic vehicle can do — accelerating, halting — while adding its own ebike tricks.
The syntax for that is class Child extends Parent:
class Ebike extends Vehicle {
ringBell() {
alert(`${this.name} rings its bell!`);
}
}
let ebike = new Ebike("City Ebike");
ebike.accelerate(5); // City Ebike accelerates to 5.
ebike.ringBell(); // City Ebike rings its bell!
An Ebike instance can call both its own method ebike.ringBell() and the inherited ebike.accelerate(). The class only wrote ringBell, yet accelerate works anyway.
Try it live. The same ebike object answers to both methods — one it defines, one it inherits — and each writes its result into the log below:
What does extends actually do? Nothing magical — it wires up the same prototype chain you already know. It sets Ebike.prototype.[[Prototype]] to Vehicle.prototype. When a property isn’t found on Ebike.prototype, the engine follows that link up to Vehicle.prototype.
So resolving ebike.accelerate walks the chain from the bottom:
- The
ebikeobject itself — noaccelerate. - Its prototype,
Ebike.prototype— hasringBell, but noaccelerate. - Its prototype,
Vehicle.prototype— here’saccelerate. Done.
This is the same mechanism the language uses for its own built-ins. As covered in Native prototypes, Date.prototype.[[Prototype]] is Object.prototype, which is why every date can call generic object methods like toString. Your classes plug into that same chain.
Overriding a method
By default, any method a child doesn’t define is inherited from the parent verbatim. Define a method with the same name in the child, and the child’s version wins for instances of the child:
class Ebike extends Vehicle {
halt() {
// ...now this will be used for ebike.halt()
// instead of halt() from class Vehicle
}
}
Total replacement is rarely what you want, though. More often you want to keep the parent’s work and wrap something around it — run the original, then add a step, or prepare something first and then delegate. For that, classes give you super:
super.method(...)calls a method from the parent.super(...)calls the parent constructor, and only from inside a constructor.
Here the ebike rings its bell automatically whenever it halts. Its halt calls the parent’s halt first, then adds ringBell:
class Vehicle {
constructor(name) {
this.speed = 0;
this.name = name;
}
accelerate(speed) {
this.speed = speed;
alert(`${this.name} accelerates to ${this.speed}.`);
}
halt() {
this.speed = 0;
alert(`${this.name} comes to a halt.`);
}
}
class Ebike extends Vehicle {
ringBell() {
alert(`${this.name} rings its bell!`);
}
halt() {
super.halt(); // call parent halt
this.ringBell(); // and then ring
}
}
let ebike = new Ebike("City Ebike");
ebike.accelerate(5); // City Ebike accelerates to 5.
ebike.halt(); // City Ebike comes to a halt. City Ebike rings its bell!
Ebike.halt reuses the parent’s zero-the-speed logic through super.halt(), then layers on its own behavior. No copy-paste of the parent’s body.
Press halt below and watch two lines appear from a single call: the parent’s message first, then the ebike’s. Edit the code to remove super.halt() and re-run to see the parent step vanish:
Overriding constructor
Constructors add a wrinkle.
So far, Ebike had no constructor of its own. When a class extends another and doesn’t declare a constructor, the engine generates an “empty” one for you. Per the specification, it looks like this:
class Ebike extends Vehicle {
// generated for extending classes without own constructors
constructor(...args) {
super(...args);
}
}
It forwards every argument straight to the parent constructor. That’s why new Ebike("City Ebike") worked earlier even though we never wrote a constructor.
Now write your own constructor — say Ebike needs a batteryRange alongside name:
class Vehicle {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Ebike extends Vehicle {
constructor(name, batteryRange) {
this.speed = 0;
this.name = name;
this.batteryRange = batteryRange;
}
// ...
}
// Doesn't work!
let ebike = new Ebike("City Ebike", 60); // Error: this is not defined.
An error, and ebikes can no longer be created. The rule behind it:
- A constructor in an inheriting class must call
super(...), and it must do so before touchingthis.
Why does the language insist on that? The reason is real, not arbitrary.
JavaScript treats the constructor of an inheriting class differently from an ordinary function. Such a constructor — a “derived constructor” — carries a hidden marker [[ConstructorKind]]: "derived". That marker changes what new does:
- Run a regular function with
new, and it creates a fresh empty object and binds it tothis. - Run a derived constructor with
new, and it does not create that object. It leaves the job to the parent constructor.
So a derived constructor must call super() to let the parent build the object that becomes this. Skip that call, or reach for this before it, and this doesn’t exist yet — hence this is not defined.
Add the super(name) call first, and everything works:
class Vehicle {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Ebike extends Vehicle {
constructor(name, batteryRange) {
super(name);
this.batteryRange = batteryRange;
}
// ...
}
// now fine
let ebike = new Ebike("City Ebike", 60);
alert(ebike.name); // City Ebike
alert(ebike.batteryRange); // 60
super(name) runs the Vehicle constructor, which sets up this with speed and name. Only then does Ebike add batteryRange.
Overriding class fields: a tricky note
You can override class fields, not just methods. But there’s a surprising rule when an overridden field is read inside the parent constructor — and it differs from what most languages do.
class Vehicle {
kind = 'vehicle';
constructor() {
alert(this.kind); // (*)
}
}
class Ebike extends Vehicle {
kind = 'ebike';
}
new Vehicle(); // vehicle
new Ebike(); // vehicle
Ebike overrides kind with 'ebike'. It has no constructor, so the Vehicle constructor runs. Yet both new Vehicle() and new Ebike() print vehicle at line (*).
The parent constructor always sees its own field value, never the child’s override.
That feels wrong until you compare it with methods. Here’s the same shape, but kind is replaced by a showKind() method:
class Vehicle {
showKind() { // instead of this.kind = 'vehicle'
alert('vehicle');
}
constructor() {
this.showKind(); // instead of alert(this.kind);
}
}
class Ebike extends Vehicle {
showKind() {
alert('ebike');
}
}
new Vehicle(); // vehicle
new Ebike(); // ebike
Now the output splits: new Ebike() prints ebike. The parent constructor calls the overridden method. That’s the behavior you’d expect. So why do fields behave differently?
The answer is timing — when each field gets initialized:
- For a base class (one that extends nothing), fields are set up right before the constructor body runs.
- For a derived class, fields are set up immediately after
super()returns.
Ebike is derived and has no explicit constructor, which is the same as constructor(...args) { super(...args); }. So new Ebike() calls super(), the Vehicle constructor runs to completion, and only after that do the Ebike fields get assigned. During the parent constructor there is no Ebike.kind yet, so Vehicle’s own kind is what’s read.
Methods dodge this because they don’t live on the instance at all — they’re on the prototype, resolved dynamically at call time, so the override is already reachable.
This split between fields and methods is specific to JavaScript. It only bites when an overridden field is consumed inside the parent constructor, which is uncommon. If it ever causes trouble, replace the field with a method or a getter/setter, and the override resolves normally.
Run both versions side by side. The parent constructor reads the value the exact same way in each — yet the field version ignores Ebike’s override while the method version honors it:
Super: internals, [[HomeObject]]
Time to look under the hood of super. There’s a genuinely interesting puzzle here.
Here’s the provocation: from everything covered so far, super shouldn’t be able to work at all.
Think about the mechanics. When an object method runs, it receives the current object as this. If that method calls super.method(), the engine needs the parent’s method — that is, a method from the prototype above the one where the current method is defined. Where does it get it?
The obvious guess: use this. The engine has this, so grab this.__proto__.method. That naive idea falls apart, as the next example shows. We’ll use plain objects instead of classes to keep it simple.
Here ebike.__proto__ = vehicle. Inside ebike.honk() we try to reach vehicle.honk() via this.__proto__:
let vehicle = {
name: "Vehicle",
honk() {
alert(`${this.name} honks.`);
}
};
let ebike = {
__proto__: vehicle,
name: "Ebike",
honk() {
// that's how super.honk() could presumably work
this.__proto__.honk.call(this); // (*)
}
};
ebike.honk(); // Ebike honks.
Line (*) pulls honk off the prototype (vehicle) and calls it with the current object as context. The .call(this) matters: a plain this.__proto__.honk() would run the parent honk with this set to the prototype rather than the real object. With two levels, this works — you get the right output.
Add a third object to the chain, though, and it collapses:
let vehicle = {
name: "Vehicle",
honk() {
alert(`${this.name} honks.`);
}
};
let ebike = {
__proto__: vehicle,
honk() {
// ...bounce around ebike-style and call parent (vehicle) method
this.__proto__.honk.call(this); // (*)
}
};
let cargoBike = {
__proto__: ebike,
honk() {
// ...do something cargo-style and call parent (ebike) method
this.__proto__.honk.call(this); // (**)
}
};
cargoBike.honk(); // Error: Maximum call stack size exceeded
cargoBike.honk() now blows the stack. Trace it and the reason surfaces. In both (*) and (**), this is the object the outermost call started with — cargoBike. That’s the fundamental rule: every method receives the current object as this, not the prototype it happens to live on.
So in both lines, this.__proto__ evaluates to the same thing: ebike. Neither line ever climbs past ebike.
Walk it step by step:
cargoBike.honk()runs line(**). It callsebike.honkwiththis = cargoBike.// inside cargoBike.honk() we have this = cargoBike this.__proto__.honk.call(this) // (**) // becomes cargoBike.__proto__.honk.call(this) // that is ebike.honk.call(this);- Inside
ebike.honk, line(*)tries to go one level higher — butthisis stillcargoBike, sothis.__proto__.honkisebike.honkagain.// inside ebike.honk() we also have this = cargoBike this.__proto__.honk.call(this) // (*) // becomes cargoBike.__proto__.honk.call(this) // or (again) ebike.honk.call(this); - So
ebike.honkcallsebike.honkforever. It can’t ascend, and the stack overflows.
this alone cannot solve this. The engine needs to know where the running method was defined, independent of what this currently is.
[[HomeObject]]
The fix is another hidden property on functions: [[HomeObject]].
When a function is written as a method inside a class or an object literal, its [[HomeObject]] is set to that class or object. super then reads [[HomeObject]], takes its prototype, and looks up the parent method there — no reliance on this at all.
Here’s the same three-level chain, now using real super:
let vehicle = {
name: "Vehicle",
honk() { // vehicle.honk.[[HomeObject]] == vehicle
alert(`${this.name} honks.`);
}
};
let ebike = {
__proto__: vehicle,
name: "Ebike",
honk() { // ebike.honk.[[HomeObject]] == ebike
super.honk();
}
};
let cargoBike = {
__proto__: ebike,
name: "Cargo Ebike",
honk() { // cargoBike.honk.[[HomeObject]] == cargoBike
super.honk();
}
};
// works correctly
cargoBike.honk(); // Cargo Ebike honks.
Each method carries its own [[HomeObject]]. Inside cargoBike.honk, super.honk() looks at cargoBike’s prototype (ebike). Inside ebike.honk, super.honk() looks at ebike’s prototype (vehicle). The lookup steps up the chain correctly because it’s anchored to where each method lives, not to the ever-constant this.
Methods are not “free”
Functions in JavaScript are normally “free”: unbound to any object, copyable between objects, callable with whatever this you supply. [[HomeObject]] dents that principle. A method now remembers the object it was defined in, and that bond can’t be reassigned — [[HomeObject]] is fixed forever.
The only feature that reads [[HomeObject]] is super. So a method that never uses super is still effectively free — copy it around freely. A method that does use super can misbehave once moved, because it keeps resolving super relative to its original home.
Here’s what goes wrong:
let scooter = {
announce() {
alert(`I'm a scooter`);
}
};
// ebike inherits from scooter
let ebike = {
__proto__: scooter,
announce() {
super.announce();
}
};
let boat = {
announce() {
alert("I'm a boat");
}
};
// ferry inherits from boat
let ferry = {
__proto__: boat,
announce: ebike.announce // (*)
};
ferry.announce(); // I'm a scooter (?!?)
ferry.announce() announces “I’m a scooter” — clearly not what a ferry should say. Here’s the chain of cause:
- Line
(*)copiedebike’sannounceontoferry, perhaps to avoid duplicating code. - That method’s
[[HomeObject]]is stillebike, frozen at the moment it was defined. Copying can’t change it. - Its body calls
super.announce(), which climbs fromebiketoscooter— completely ignoring that the method now lives onferry, whose real chain leads toboat.
See the surprise for yourself. ferry sits on top of boat, yet asking the ferry to speak produces the scooter’s line, because the borrowed method’s [[HomeObject]] is still ebike:
Methods, not function properties
[[HomeObject]] is set for methods in both classes and plain objects. But in an object literal it’s only set when you use the shorthand method syntax method(), not the assignment form method: function().
The distinction can look cosmetic, but the engine treats the two forms differently. Below, the assignment form leaves [[HomeObject]] unset, so super has nothing to resolve against and the call fails:
let vehicle = {
honk: function() { // intentionally writing like this instead of honk() {...
// ...
}
};
let ebike = {
__proto__: vehicle,
honk: function() {
super.honk();
}
};
ebike.honk(); // Error calling super (because there's no [[HomeObject]])
The takeaway: if a method uses super, define it with method shorthand.
Summary
- Extend a class with
class Child extends Parent:- This sets
Child.prototype.__proto__toParent.prototype, so instances inherit the parent’s methods.
- This sets
- Overriding a constructor:
- The child constructor must call
super()before it usesthis, because a derived constructor relies on the parent to create thethisobject.
- The child constructor must call
- Overriding another method:
- Call the parent’s version with
super.method()from inside the child’s method.
- Call the parent’s version with
- Internals:
- Each method remembers its class or object in
[[HomeObject]]. That’s howsuperfinds the parent method — via the home object’s prototype, not viathis. - Because of that fixed bond, copying a
super-using method to a different object can produce wrong results.
- Each method remembers its class or object in
And a related point: arrow functions have no this or super of their own, so they transparently borrow both from the surrounding code.