bastianplsfix

Classes

A class is a factory for objects, and it is syntax for setting up the prototype chain that prototypes and inheritance describes. Nothing new happens underneath. What you get for the syntax is worth having anyway: recognisable structure, private state that was not previously possible, and inheritance the type checker can actually see.

The one thing to know going in: how classes look is quite different from how they work. A method you write inside a class body becomes a property of a different object. A class is a function. Both are backward compatibility rather than design, and both are harmless once you have looked at them, which is what the middle of this entry is for.

You do not need a class to make an object. An object literal will do, the case the objects page makes at length, which is why the singleton pattern has no place here and classes come up less than in languages where they are the only way in.

Create programs/classes.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.

a class, and what this means inside it

class Person {
#firstName: string;

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

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

static extractNames(persons: Person[]): string[] {
return persons.map((person) => person.#firstName);
}
}

const ada = new Person("Ada");
const grace = new Person("Grace");

Deno.test("a class, and what this means inside it", () => {
assertStrictEquals(ada.describe(), "Person named Ada");
assertStrictEquals(grace.describe(), "Person named Grace");

assertEquals(Person.extractNames([ada, grace]), ["Ada", "Grace"]);
});
Check programs/classes.test.ts
running 1 test from ./programs/classes.test.ts
a class, and what this means inside it ... ok (0ms)

ok | 1 passed | 0 failed (2ms)

Four things in one body, and the class sits at module level because most of this entry keeps measuring it. The constructor runs after a new instance exists, and this is that instance. #firstName is a private field, which must be declared before it can be assigned and is the subject of the private class members page. describe is a method, and this inside it is whichever object received the call, from the value of this. And extractNames is static, so it hangs off the class.

Note what extractNames can do: reach into #firstName on objects that are not this. Private means private to the class, not to the instance.

instance state, two ways

Deno.test("instance state, two ways", () => {
class Container {
value: string;
constructor(value: string) {
this.value = value;
}
}
class Counter {
count = 0;
inc(): this {
this.count++;
return this;
}
}

assertStrictEquals(new Container("oak").value, "oak");
assertEquals(Reflect.ownKeys(new Container("oak")), ["value"]);

assertStrictEquals(new Counter().inc().inc().count, 2);
assertEquals(Reflect.ownKeys(new Counter()), ["count"]);
});
instance state, two ways ... ok (0ms)

A public property can be assigned in the constructor or declared as a field with an initial value. Either way it is an ordinary own property, which is why Reflect.ownKeys sees both. Public instance data is much more common in JavaScript than in languages where most state is private, and it is not a smell here.

a class can be an expression

Deno.test("a class can be an expression", () => {
const Anon = class {};
const NamedExpr = class Inner {
who(): string {
return Inner.name;
}
};

assertStrictEquals(Anon.name, "Anon");
assertStrictEquals(NamedExpr.name, "Inner");
assertStrictEquals(new NamedExpr().who(), "Inner");
});
a class can be an expression ... ok (0ms)

Classes come in declarations and expressions, and the expression form can be anonymous or named. This works exactly like the function version, from the functions page's where a name comes from and a named function expression can reach itself: an anonymous class assigned to a variable takes that variable's name, while a named class expression keeps its own name regardless, and that name is visible only inside the body. One difference a declaration carries over from the scope and declarations page: unlike a function, a class is not ready early, which a function is ready early; a class is not measured.

a class is two connected objects

The intro promised a gap between how classes look and how they work. Predict where describe and firstName live on an instance:

Deno.test("a class is two connected objects", () => {
assertEquals(Reflect.ownKeys(Person), [
"length",
"name",
"prototype",
"extractNames",
]);
assertEquals(Reflect.ownKeys(Person.prototype), [
"constructor",
"describe",
]);

assertStrictEquals(Person.length, 1);
assertStrictEquals(Person.name, "Person");
assertStrictEquals(typeof Person, "function");

assertEquals(Reflect.ownKeys(ada), ["firstName", "describe"]);
assertStrictEquals(Object.getPrototypeOf(ada), Person.prototype);
assertStrictEquals(
Object.getPrototypeOf(ada),
Object.getPrototypeOf(grace),
);
});
Check programs/classes.test.ts
running 4 tests from ./programs/classes.test.ts
...
a class is two connected objects ... FAILED (9ms)

ERRORS

a class is two connected objects => ./programs/classes.test.ts:71:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

+ [
+ "firstName",
+ "describe",
+ ]
- []

FAILURES

a class is two connected objects => ./programs/classes.test.ts:71:11

FAILED | 3 passed | 1 failed (10ms)

error: Test failed

The instance owns nothing at all. One class body, two objects: the static method landed on Person, the prototype method landed on Person.prototype, and the two lines you wrote next to each other in the body ended up on different objects, chosen by the word static. length is the constructor's declared arity and name is the class name, both of which Person has because a class is a function, which the typeof pins. As for the instance, describe is not on ada, because ada inherits it through Person.prototype, and firstName is not there either, because a private field is not a property. Correct the prediction to []:

a class is two connected objects ... ok (0ms)

That is the arrangement the prototypes page built by hand in sharing one method is the whole point: behavior on a shared prototype, data in own slots, one describe serving every instance. A class is a way to write it down.

.prototype is not __proto__

Deno.test(".prototype is not __proto__", () => {
assert(Object.hasOwn(Person, "prototype"));
assertFalse(Object.hasOwn(ada, "prototype"));

assertStrictEquals(Object.getPrototypeOf(ada), Person.prototype);
});
.prototype is not __proto__ ... ok (0ms)

Two similar names for unrelated things, and the confusion is worth heading off. Person.prototype is a property of the class holding the object that will become the prototype of its instances, and it is only special because new uses it; the suggestion that it should have been called .instancePrototype is a good one, and thinking of it under that name fixes the confusion permanently. __proto__ is the accessor for an object's own prototype, and on Deno it is a neutered stub anyway, from the objects as dictionaries page's on Deno, __proto__ as a property is inert, so Object.getPrototypeOf is the thing to use, and it never reads ambiguously. An instance does not have a .prototype; only a function does, which the middle assertion pins.

.constructor points back

Every instance can name the class that made it, and can even build another of the same kind. Write the second half the obvious way:

Deno.test(".constructor points back", () => {
assertStrictEquals(Person.prototype.constructor, Person);
assertStrictEquals(ada.constructor.name, "Person");

const similar = new ada.constructor("Hedy");
assert(similar instanceof Person);
});
Check programs/classes.test.ts
TS2351 [ERROR]: This expression is not constructable.
Type 'Function' has no construct signatures.
const similar = new ada.constructor("Hedy");
~~~~~~~~~~~~~~~~
at file:///programs/classes.test.ts:106:25

error: Type checking failed.

Object.prototype.constructor is typed Function, so TypeScript will not let you new it without being told what it constructs. That is a fair objection, since nothing guarantees a .constructor was not reassigned, and it is a reason to reach for this rarely. The cast says what we know:

Deno.test(".constructor points back", () => {
assertStrictEquals(Person.prototype.constructor, Person);
assertStrictEquals(ada.constructor.name, "Person");

const similar = new (ada.constructor as new (name: string) => Person)(
"Hedy",
);
assert(similar instanceof Person);
assertStrictEquals(similar.describe(), "Person named Hedy");
});
.constructor points back ... ok (0ms)

Person.prototype.constructor is Person, which every instance inherits. It exists for backward compatibility and buys two small things: the name of the class that made an object, useful in a log or an error message, and the ability to make another object of the same kind without naming the class.

a dispatched call and a direct call

Deno.test("a dispatched call and a direct call", () => {
assertStrictEquals(ada.describe(), "Person named Ada");
assertStrictEquals(
Person.prototype.describe.call(ada),
"Person named Ada",
);

const bare = Object.create(null) as Record<string, unknown>;

assertThrows(
() => (bare as unknown as { toString(): string }).toString(),
TypeError,
"is not a function",
);

assertStrictEquals(
Object.prototype.toString.call(bare),
"[object Object]",
);
});
a dispatched call and a direct call ... ok (0ms)

Two ways to reach the same method, and the difference explains what a method call is. ada.describe() is dispatched: the runtime walks ada's prototype chain for the first object with an own describe, finds it on Person.prototype, and calls it with this set to ada. Two steps, lookup then invoke, and the receiver comes from what was left of the dot. Person.prototype.describe.call(ada) is direct: you name the function and supply the receiver yourself, and no lookup happens. Wherever in the chain a method lives, this is the instance, which is what lets describe reach #firstName even though the method is not on the object that has the field.

The second half is what the direct form is for: borrowing a method from somewhere an object cannot reach. A null-prototype object inherits nothing, so it has no toString to dispatch to and the dispatched call throws, but naming the function directly works fine, because the method never needed to be inherited, only supplied with a receiver. This is the safe way to use any Object.prototype method on an object you do not control, and it also covers a dictionary whose keys come from outside shadowing the method with an entry of its own; it is the same move the prototypes page's linter demanded for isPrototypeOf.

static members are inherited

Heir declares nothing. Predict whether make is its own:

Deno.test("static members are inherited", () => {
class Base {
static make(): string {
return "made";
}
}
class Heir extends Base {}

assertStrictEquals(Heir.make(), "made");
assert("make" in Heir);
assertEquals(Object.hasOwn(Heir, "make"), true);
});
Check programs/classes.test.ts
running 8 tests from ./programs/classes.test.ts
...
static members are inherited ... FAILED (8ms)

ERRORS

static members are inherited => ./programs/classes.test.ts:134:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- false
+ true

FAILURES

static members are inherited => ./programs/classes.test.ts:134:11

FAILED | 7 passed | 1 failed (11ms)

error: Test failed

Heir does not have make; it inherits it, exactly the way an instance inherits a method, because the class objects form a prototype chain of their own. Nothing was copied down at extends time, which is what the prediction assumed. Correct it to assertFalse:

static members are inherited ... ok (0ms)

That is one of two prototype chains in play whenever extends appears, and the subclassing page takes both of them apart in extends builds two prototype chains. It is also where this convenience turns into a pitfall, for static private members.

a static block runs once, with private access

Deno.test("a static block runs once, with private access", () => {
class Registry {
static #known = new Set<string>();

static {
Registry.#known.add("seeded");
}

static has(name: string): boolean {
return Registry.#known.has(name);
}
}

assert(Registry.has("seeded"));
assertFalse(Registry.has("other"));
});
a static block runs once, with private access ... ok (0ms)

Fields and constructors set up instance state; static fields and static blocks set up class state, and a static block runs once, when the class is created. You could put the same code after the class at the top level of the module, and there are two reasons not to: the class-related code stays in the class, and the block can reach private slots, which nothing outside the body can.

a static factory says what it makes

Deno.test("a static factory says what it makes", () => {
class Point {
constructor(readonly x: number, readonly y: number) {}

static fromPolar(radius: number, angle: number): Point {
return new Point(radius * Math.cos(angle), radius * Math.sin(angle));
}
}

const fromPolar = Point.fromPolar(13, 0.39479111969976155);

assertStrictEquals(Math.round(fromPolar.x), 12);
assertStrictEquals(Math.round(fromPolar.y), 5);
assert(fromPolar instanceof Point);
});
a static factory says what it makes ... ok (0ms)

Point.fromPolar(radius, angle) and new Point(x, y) both make a point, and only one of them tells you which coordinate system you are in. That is the argument for static factory methods, and the standard library agrees: Array.from and Object.create are the same idea. This is the best reason to use a static member at all, along with anything that needs access to private slots. It is worth being consistent: either a class has a public constructor or it has factories, and mixing them means callers have to learn which is which.

prefer module functions to static members

Deno.test("prefer module functions to static members", () => {
const KNOWN_UNITS = new Set(["cm", "in"]);

function isKnownUnit(unit: string): boolean {
return KNOWN_UNITS.has(unit);
}

class Measurement {
constructor(readonly amount: number, readonly unit: string) {}

valid(): boolean {
return isKnownUnit(this.unit);
}
}

assert(new Measurement(5, "cm").valid());
assertFalse(new Measurement(5, "px").valid());
});
prefer module functions to static members ... ok (0ms)

isKnownUnit and KNOWN_UNITS sit beside the class rather than in it, and the method calls the function. In real code they would sit at the top of the module, where not exporting them already gives you privacy, the mechanism the modules page opens with. A static member is a public part of the class's surface, so making one out of a helper adds it to the API for no gain; the two exceptions are the ones from the previous steps, static factories and anything that must touch private slots. The same instinct scales up: an algorithm that involves instances of three classes is not a method of any of them, and forcing it to be one is how a class becomes a place things get put.

In practice

Related