Class-related types in TypeScript

[2020-04-13] dev, javascript, typescript
(Ad, please don’t block)

Update 2020-04-13: Major rewrite of this blog post.


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

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);

The following diagram shows the runtime structure of class Counter:

There are two prototype chains of objects in this diagram:

  • Class (left-hand side): The static prototype chain consists of the objects that make up the class Counter. The prototype object of class Counter is its superclass, Object.
  • Instance (right-hand side): The instance prototype chain consists of the objects that make up the instance myCounter. The chain starts with the instance myCounter and continues with Counter.prototype (which holds the prototype methods of the class) and Object.prototype (which holds the prototype methods of class Object).

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

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:

  • TypeScript does not distinguish between inherited properties (such as .increment) and own properties (such as .value).
  • As an aside, private properties are ignored by interfaces and can’t be specified via them. This is expected given that private data is for internal purposes only.

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.

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, this approach doesn’t really work:

  • We can’t new-call Person because JsonStatic does not have a construct signature.
  • If Person has static properties beyond .fromJson(), TypeScript won’t let us access them.

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;
}

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:

function func(instanceOfColor: Color) {}
func(new Color('green'));

Here is the proof that Color really is an interface:

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

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.

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

Further reading