Tackling TypeScript
Please support this book: buy it or donate
(Ad, please don’t block.)


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

17.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 2: 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 fig. 2 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.

17.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:

17.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.

17.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: any): JsonInstance;
}

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

We use these interfaces in the following code:

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

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

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

The following way of making this check may seem like a good idea:

const Person: JsonStatic = class implements JsonInstance {
  // ···
};

However, that doesn’t really work:

17.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 class Object itself:

/**
 * Provides functionality common to all JavaScript objects.
 */
declare var Object: ObjectConstructor;

interface ObjectConstructor {
  new(value?: any): Object;
  (): any;
  (value: any): any;

  /** A reference to the prototype for a class of objects. */
  readonly prototype: Object;

  /**
   * Returns the prototype of an object.
   * @param o The object that references the prototype.
   */
  getPrototypeOf(o: any): any;

}

On the other hand, interface Object is for instances of Object:

interface Object {
  /** The initial value of Object.prototype.constructor is the standard built-in Object constructor. */
  constructor: Function;

  /** Returns a string representation of an object. */
  toString(): string;
}

The name Object is used twice, at two different language levels:

17.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];
}

17.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.

17.4.1.1 Switching off structural typing

We can make the two groups of objects incompatible by adding private properties:

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

const person: Person = new Person('Jane');

// @ts-expect-error: Type 'Person' is not assignable to type 'Color'.
//   Types have separate declarations of a private property
//   'branded'. (2322)
const color: Color = person;

The private properties switch off structural typing in this case.

17.5 Further reading