bastianplsfix

Subclassing

extends connects two classes, and it connects them twice. The subclass inherits from the superclass, which is how static members are shared. And the subclass's .prototype inherits from the superclass's .prototype, which is how instance methods are shared.

Two prototype chains, running in parallel. Every surprising thing in this entry follows from that, or from the order in which the two constructors and their fields run.

Create programs/subclassing.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

extends, super(), and super.method()

Person returns from the classes page, this time with an heir, and both sit at module level because the whole entry measures them. Write Employee.describe without the override keyword first:

class Person {
#firstName: string;

constructor(firstName: string) {
this.#firstName = firstName;
}

describe(): string {
return `Person named ${this.#firstName}`;
}

static kind(): string {
return "person";
}
}

class Employee extends Person {
#title: string;

constructor(firstName: string, title: string) {
super(firstName);
this.#title = title;
}

describe(): string {
return `${super.describe()} (${this.#title})`;
}
}

Deno.test("extends, super(), and super.method()", () => {
const ada = new Employee("Ada", "CTO");

assertStrictEquals(ada.describe(), "Person named Ada (CTO)");
assert(ada instanceof Employee);
assert(ada instanceof Person);
});
Check programs/subclassing.test.ts
TS4114 [ERROR]: This member must have an 'override' modifier because it overrides a member in the base class 'Person'.
describe(): string {
~~~~~~~~
at file:///programs/subclassing.test.ts:33:3

error: Type checking failed.

TS4114 is noImplicitOverride at work, which Deno turns on: if you mean to replace an inherited method you have to say so, which catches the case where you misspell the name and silently add a new method instead. Add the keyword:

  override describe(): string {
return `${super.describe()} (${this.#title})`;
}
Check programs/subclassing.test.ts
running 1 test from ./programs/subclassing.test.ts
extends, super(), and super.method() ... ok (0ms)

ok | 1 passed | 0 failed (1ms)

super(...) calls the superclass constructor, before anything touches this. super.describe() calls the superclass's version of an overridden method, which is how you extend behavior rather than replacing it, and why Ada's description carries both halves. Both instanceof checks pass because, as the rest of this entry measures, Person.prototype sits in ada's chain right behind Employee.prototype.

an omitted constructor forwards its arguments

Deno.test("an omitted constructor forwards its arguments", () => {
class Heir extends Person {}

assertStrictEquals(new Heir("Grace").describe(), "Person named Grace");
assertStrictEquals(Heir.kind(), "person");
});
an omitted constructor forwards its arguments ... ok (0ms)

A subclass that adds no state needs no constructor: leave it out and arguments go straight through to the superclass. If you write one, you must call super(). The second assertion is the classes page's static members are inherited meeting extends: kind rides the class chain down to Heir.

this does not exist until super() has returned

Move the field assignment above the super() call:

Deno.test("this does not exist until super() has returned", () => {
class TooEager extends Person {
#title: string;

constructor(firstName: string) {
this.#title = "eager";
super(firstName);
}

title(): string {
return this.#title;
}
}

assertThrows(() => new TooEager("Ada"), ReferenceError);
});
Check programs/subclassing.test.ts
TS17009 [ERROR]: 'super' must be called before accessing 'this' in the constructor of a derived class.
this.#title = "eager";
~~~~
at file:///programs/subclassing.test.ts:59:9

error: Type checking failed.

The checker refuses with TS17009, and the linter refuses the same line through no-this-before-super, so pinning this one takes two signatures. Sign both to hear the runtime's version:

Deno.test("this does not exist until super() has returned", () => {
class TooEager extends Person {
#title: string;

constructor(firstName: string) {
// @ts-expect-error: 'super' must be called before accessing 'this' in the constructor of a derived class.
// deno-lint-ignore no-this-before-super
this.#title = "eager";
super(firstName);
}

title(): string {
return this.#title;
}
}

assertThrows(
() => new TooEager("Ada"),
ReferenceError,
"Must call super constructor in derived class before accessing 'this'",
);
});
this does not exist until super() has returned ... ok (0ms)

A ReferenceError rather than a TypeError, which is the runtime saying the binding is not merely empty but not yet established. The reason explains the rule instead of just stating it: instances are created in the base class. Given C extends B extends A, calling new C() runs C's constructor, which calls B's, which calls A's, and A is where the object comes into existence, with each constructor adding its own state on the way back down. Before super() returns, there is no object for this to refer to.

extends builds two prototype chains

Deno.test("extends builds two prototype chains", () => {
assertStrictEquals(Object.getPrototypeOf(Employee), Person);
assertStrictEquals(Object.getPrototypeOf(Person), Function.prototype);

assertStrictEquals(
Object.getPrototypeOf(Employee.prototype),
Person.prototype,
);
assertStrictEquals(
Object.getPrototypeOf(Person.prototype),
Object.prototype,
);
});
extends builds two prototype chains ... ok (0ms)

There it is, asserted. Employee inherits from Person and Person from Function.prototype, because classes are functions. Separately, Employee.prototype inherits from Person.prototype and that from Object.prototype. Each class contributes one link to the instance chain while sitting in a chain of its own: static inheritance travels along the first pair, method inheritance along the second, and the two are genuinely independent, which the next two steps make unmistakable.

a base class is not a class that extends Object

Deno.test("a base class is not a class that extends Object", () => {
class BaseClass {}
class DerivedFromObject extends Object {}

assertStrictEquals(Object.getPrototypeOf(BaseClass), Function.prototype);
assertStrictEquals(Object.getPrototypeOf(DerivedFromObject), Object);

assert(new BaseClass() instanceof Object);
assert(new DerivedFromObject() instanceof Object);
});
a base class is not a class that extends Object ... ok (0ms)

A base class has no superclass, and its own prototype is Function.prototype. A class that writes extends Object is a derived class, and its prototype is Object itself. The instances are indistinguishable in the way that matters: both inherit from Object.prototype, so both are instances of Object. The difference lives only in the class chain, which is to say only in what static members they inherit.

Array does not extend Object; arrays are Objects

Every array is an instance of Object, so predict where Array itself sits:

Deno.test("Array does not extend Object; arrays are Objects", () => {
assertEquals(Object.getPrototypeOf(Array) === Object, true);

assert([] instanceof Array);
assert([] instanceof Object);
});
Check programs/subclassing.test.ts
running 6 tests from ./programs/subclassing.test.ts
...
Array does not extend Object; arrays are Objects ... FAILED (8ms)

ERRORS

Array does not extend Object; arrays are Objects => ./programs/subclassing.test.ts:102:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- false
+ true

FAILURES

Array does not extend Object; arrays are Objects => ./programs/subclassing.test.ts:102:11

FAILED | 5 passed | 1 failed (10ms)

error: Test failed

Array is not a subclass of Object. It is a base class, its prototype is Function.prototype, and it inherits no static members from Object; and yet every array is an instance of Object, because Array.prototype inherits from Object.prototype. In a language with a single inheritance graph that combination would be impossible; here the classes are related one way and their instances another. Correct the prediction and pin the whole shape:

Deno.test("Array does not extend Object; arrays are Objects", () => {
assertStrictEquals(Object.getPrototypeOf(Array), Function.prototype);
assertFalse(Object.getPrototypeOf(Array) === Object);

assert([] instanceof Array);
assert([] instanceof Object);
assertStrictEquals(
Object.getPrototypeOf(Array.prototype),
Object.prototype,
);
});
Array does not extend Object; arrays are Objects ... ok (0ms)

Two reasons it is arranged this way. Backward compatibility, since this predates classes by a long way. And more interestingly: base classes are where instances are actually created, and an array needs to create its own, because arrays have internal machinery that cannot be bolted onto an object Object made.

field initializers run in a fixed order

Deno.test("field initializers run in a fixed order", () => {
const order: string[] = [];

class SuperOrder {
superField = order.push("superField");

constructor() {
order.push("super-constructor");
}
}
class SubOrder extends SuperOrder {
subField = order.push("subField");

constructor() {
order.push("before super()");
super();
order.push("after super()");
}
}

new SubOrder();

assertEquals(order, [
"before super()",
"superField",
"super-constructor",
"subField",
"after super()",
]);
});
field initializers run in a fixed order ... ok (0ms)

Five entries, and the two field initializers are in the middle rather than at the start. Read it as: the subclass constructor begins, super() sets up the superclass's fields and then runs the superclass constructor body, control returns, then the subclass's fields are initialized, and finally the rest of the subclass constructor runs. The rule underneath: a base class's fields run immediately before its constructor body, and a derived class's fields run immediately after super() returns. Which is consistent with the previous steps, since fields need this, and this does not exist until the base class has made it.

never call an overridable method from a constructor

That ordering is the mechanism behind a real bug. Sub overrides init to set value to 99, and the base constructor calls init, so predict what an instance holds:

Deno.test("never call an overridable method from a constructor", () => {
class Base {
constructor() {
this.init();
}

init(): void {}
}
class Sub extends Base {
value = 1;

override init(): void {
this.value = 99;
}
}

assertStrictEquals(new Sub().value, 99);
});
Check programs/subclassing.test.ts
running 8 tests from ./programs/subclassing.test.ts
...
never call an overridable method from a constructor ... FAILED (9ms)

ERRORS

never call an overridable method from a constructor => ./programs/subclassing.test.ts:145:11
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- 1
+ 99

FAILURES

never call an overridable method from a constructor => ./programs/subclassing.test.ts:145:11

FAILED | 7 passed | 1 failed (10ms)

error: Test failed

The value is 1. The superclass constructor called init(), dynamic dispatch found the subclass's override, and the override set value to 99. Then super() returned, and the subclass's field initializer ran and put 1 there. Nothing threw, no tool complained, the subclass's own override was defeated by its own field declaration, and the only clue is a value that is stubbornly the default. Correct the prediction to 1:

never call an overridable method from a constructor ... ok (0ms)

So a constructor must not call a method that a subclass can override. This is standard advice in other languages for related reasons; here the specific mechanism is field initialization order, and it is worth being able to explain rather than just obey. If the base class needs work done that a subclass can customise, do it in a separate method the caller invokes, or take the value as a constructor argument.

not every object is an instance of Object

Deno.test("not every object is an instance of Object", () => {
const bare = Object.create(null);

assertStrictEquals(typeof bare, "object");
assertFalse(bare instanceof Object);
assertFalse(Object.prototype instanceof Object);

assertFalse(("oak" as unknown) instanceof String);
assertFalse((123 as unknown) instanceof Number);
});
not every object is an instance of Object ... ok (0ms)

instanceof Object really means "is Object.prototype in this thing's chain", from the prototypes and inheritance page's instanceof asks about the chain, not construction. A null-prototype object has no chain, so no. Object.prototype's own prototype is null, so it is not an instance of Object either. And a primitive is never an instance of anything, whatever wrapper type you name, because it has no chain of its own to search, the split the values and references page drew first. Worth knowing mostly as a reason not to use instanceof Object as a test for "is this an object": typeof x === "object" answers a different question, and Object.create(null) shows they can disagree.

mixins, and what they cost

JavaScript has single inheritance, so a class has at most one superclass. A mixin works around that by making the superclass a parameter, and the natural spelling of its type is the first casualty:

Deno.test("mixins, and what they cost", () => {
type Constructor = new () => object;

function Named<T extends Constructor>(Sup: T) {
return class extends Sup {
name = "(Unnamed)";

describe(): string {
return `${this.constructor.name} named ${this.name}`;
}
};
}

assertStrictEquals(typeof Named, "function");
});
Check programs/subclassing.test.ts
TS2545 [ERROR]: A mixin class must have a constructor with a single rest parameter of type 'any[]'.
return class extends Sup {
~~~~~
at file:///programs/subclassing.test.ts:179:14

error: Type checking failed.

TypeScript names the only form it accepts, and that form costs an any with a lint suppression on top, in a file that otherwise has neither. One small mercy on Deno: the usual => {} spelling also trips the ban-types lint rule, and returning object instead satisfies everyone:

Deno.test("mixins, and what they cost", () => {
// deno-lint-ignore no-explicit-any
type Constructor = new (...args: any[]) => object;

function Named<T extends Constructor>(Sup: T) {
return class extends Sup {
name = "(Unnamed)";

describe(): string {
return `${this.constructor.name} named ${this.name}`;
}
};
}
function Timestamped<T extends Constructor>(Sup: T) {
return class extends Sup {
stamped = true;
};
}

class City extends Named(Object) {
constructor(name: string) {
super();
this.name = name;
}
}
class StampedCity extends Timestamped(Named(Object)) {
constructor(name: string) {
super();
this.name = name;
}
}

const paris = new City("Paris");
assertStrictEquals(paris.name, "Paris");
assertStrictEquals(paris.describe(), "City named Paris");

const berlin = new StampedCity("Berlin");
assertStrictEquals(berlin.describe(), "StampedCity named Berlin");
assert(berlin.stamped);
});
mixins, and what they cost ... ok (0ms)

Named is a function from a class to a subclass of it, so Named(Object) is a class you can extend. Note this.constructor.name reporting City rather than anything the mixin knows about, which works because the constructor back-reference from the classes page's .constructor points back points at whatever class actually made the object. And they compose, which is the whole point: Timestamped(Named(Object)) stacks two capabilities into one chain, so one class can extend a superclass and any number of mixins. The any and its suppression are the actual price of mixins in TypeScript, and the main reason to reach for them only when composition genuinely will not do.

a union when the branches are data

Deno.test("a union when the branches are data", () => {
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };

function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
}
}

assertStrictEquals(area({ kind: "circle", radius: 1 }), Math.PI);
assertStrictEquals(area({ kind: "square", side: 3 }), 9);
});
a union when the branches are data ... ok (0ms)

No extends, no super, no initialization order, and the checker proves the switch is total, the shape the branching page built in making the checker prove you covered every case over the sentinels page's discriminants. A hierarchy earns its keep when subclasses genuinely differ in behavior and callers should not know which one they have; a union is better when callers are switching on the kind anyway.

In practice

Related