HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

In this chapter, we examine types related to classes and their instances.

22.1 The two prototype chains of classes

Consider this class:

class Counter extends Object {
  static createZero() {
    return new Counter(0);
  }
  value: number;
  constructor(value: number) {
    super();
    this.value = value;
  }
  increment() {
    this.value++;
  }
}
// Static method
const myCounter = Counter.createZero();
assert.ok(myCounter instanceof Counter);
assert.equal(myCounter.value, 0);

// Instance method
myCounter.increment();
assert.equal(myCounter.value, 1);

Figure 22.1: Objects created by class Counter. Left-hand side: the class and its superclass Object. Right-hand side: The instance myCounter, the prototype properties of Counter, and the prototype methods of the superclass Object.

The diagram in figure 22.1 shows the runtime structure of class Counter. There are two prototype chains of objects in this diagram:

In this chapter, we’ll first explore instance objects and then classes as objects.

22.2 Interfaces for instances of classes

Interfaces specify services that objects provide. For example:

interface CountingService {
  value: number;
  increment(): void;
}

TypeScript’s interfaces work structurally: In order for an object to implement an interface, it only needs to have the right properties with the right types. We can see that in the following example:

const myCounter2: CountingService = new Counter(3);

Structural interfaces are convenient because we can create interfaces even for objects that already exist (i.e., we can introduce them after the fact).

If we know ahead of time that an object must implement a given interface, it often makes sense to check early if it does, in order to avoid surprises later. We can do that for instances of classes via implements:

class Counter implements CountingService {
  // ···
};

Comments:

22.3 Interfaces for classes

Classes themselves are also objects (functions). Therefore, we can use interfaces to specify their properties. The main use case here is describing factories for objects. The next section gives an example.

22.3.1 Example: converting from and to JSON

The following two interfaces can be used for classes that support their instances being converted from and to JSON:

// Converting JSON to instances
interface JsonStatic {
  fromJson(json: unknown): JsonInstance;
}

// Converting instances to JSON
interface JsonInstance {
  toJson(): unknown;
}

We use these interfaces in the following code:

class Person implements JsonInstance {
  static fromJson(json: unknown): Person {
    if (typeof json !== 'string') {
      throw new TypeError();
    }
    return new Person(json);
  }
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  toJson(): unknown {
    return this.name;
  }
}

This is how we can check right away if class Person (as an object) implements the interface JsonStatic:

type _ = Assert<Assignable<JsonStatic, typeof Person>>;

If you don’t want to use a library (with the utility types Assert and Assignable) for this purpose, you can use the following pattern:

// Assign the class to a type-annotated variable
const personImplementsJsonStatic: JsonStatic = Person;

The downside of this pattern is that it produces extra JavaScript code.

22.3.1.1 Can we do better?

It would be nice to avoid an external check – e.g., like this:

const Person = class implements JsonInstance {
  static fromJson(json: unknown): Person { // (A)
    // ···
  }
  // ···
} satisfies JsonStatic; // (B)
type Person = typeof Person.prototype; // (C)

In line B, we use the satisfies operator, which enforces that the value Person is assignable to JsonStatic while preserving the type of that value. That is important because Person should not be limited to what’s defined in JsonStatic.

Alas, this alternative approach is even more verbose and doesn’t compile. One of the compiler errors is in line C:

Type alias 'Person' circularly references itself.

Why? Type Person is mentioned in line A. Even if we rename the type Person to TPerson, that error doesn’t go away.

22.3.2 Example: TypeScript’s built-in interfaces for the class Object and for its instances

It is instructive to take a look at TypeScript’s built-in types:

On one hand, interface ObjectConstructor is for the class pointed to by the global variable Object:

declare var Object: ObjectConstructor;
interface ObjectConstructor {
  /** Invocation via `new` */
  new(value?: any): Object; // (A)
  /** Invocation via function calls */
  (value?: any): any;

  readonly prototype: Object; // (B)

  getPrototypeOf(o: any): any;
  // ···
}

On the other hand, interface Object (which is mentioned in line A and line B) is for instances of Object:

interface Object {
  constructor: Function;
  toString(): string;
  toLocaleString(): string;
  valueOf(): Object;
  hasOwnProperty(v: PropertyKey): boolean;
  isPrototypeOf(v: Object): boolean;
  propertyIsEnumerable(v: PropertyKey): boolean;
}

In other words – the name Object is used twice, at two different language levels:

22.4 Classes as types

Consider the following class:

class Color {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

This class definition creates two things.

First, a constructor function named Color (that can be invoked via new):

assert.equal(
  typeof Color, 'function'
);

Second, an interface named Color that matches instances of Color:

const green: Color = new Color('green');

Here is proof that Color really is an interface:

interface RgbColor extends Color {
  rgbValue: [number, number, number];
}

22.4.1 Pitfall: classes work structurally, not nominally

There is one pitfall, though: Using Color as a static type is not a very strict check:

class Color {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}
class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

const person: Person = new Person('Jane');
const color: Color = person; // (A)

Why doesn’t TypeScript complain in line A? That’s due to structural typing: Instances of Person and of Color have the same structure and are therefore statically compatible.

22.4.1.1 Switching off structural typing

We can turn Color into a nominal type by adding a private field (or a private property):

class Color {
  name: string;
  #isBranded = true;
  constructor(name: string) {
    this.name = name;
  }
}
class Person {
  name: string;
  #isBranded = true;
  constructor(name: string) {
    this.name = name;
  }
}

const robin: Person = new Person('Robin');
// @ts-expect-error: Type 'Person' is not assignable to type 'Color'.
// Property '#isBranded' in type 'Person' refers to a different member that
// cannot be accessed from within type 'Color'.
const color: Color = robin;

This way of switching off structural typing is called branding. Note that the private fields of Color and Person are incompatible even though they have the same name and the same type. That reflects how JavaScript works: We cannot access the private field of Color from Person and vice versa.

22.4.1.2 Use case for branding: migrating from an object type to a class

Let’s say we want to migrate the following code from the object type in line A to a class:

type Person = { // (A)
  name: string,
};

function storePerson(person: Person): void {
  // ...
}

storePerson({
  name: 'Robin',
});

In our first attempt, invoking storePerson() with an object literal still works:

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

function storePerson(person: Person): void {
  // ...
}

storePerson({
  name: 'Robin',
});

Once we brand Person, we get a compiler error:

class Person {
  name: string;
  #isBranded = true;
  constructor(name: string) {
    this.name = name;
  }
}

function storePerson(person: Person): void {
  // ...
}

// @ts-expect-error: Argument of type '{ name: string; }' is not assignable
// to parameter of type 'Person'. Property '#isBranded' is missing in type
// '{ name: string; }' but required in type 'Person'.
storePerson({
  name: 'Robin',
});

This is how we fix the error:

storePerson(
  new Person('Robin')
);

22.5 Further reading