Portrait Dr. Axel Rauschmayer
Dr. Axel Rauschmayer
Homepage | Twitter
Cover of book “JavaScript for impatient programmers”
Book, exercises, quizzes
(free to read online)
Cover of book “Deep JavaScript”
Book (50% free online)
Logo of newsletter “ES.next news”
Newsletter (free)

Types for classes in TypeScript

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

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


Table of contents:


Interfaces for classes and instances  

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:

  • 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.
  • 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).

Accordingly, Counter has two interfaces:

  • Its static interface comprises the properties of the class: .createZero, etc.
  • Its instance interface comprises the properties of its instances: .increment, etc.

An interface for instances of class Counter  

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

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.

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 characteristics). We can see that in the following example:

const myCounter2: CounterInstance = new Counter(3);

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

We can take the following measure: If a class implements an interface, TypeScript immediately checks if it will later be compatible with that interface:

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

However, if Counter is later used as an implementation for CounterInstance, then the implements doesn’t change anything. It only checks compatibility earlier. I personally like this kind of early check: You find out about problems earlier and the intended purpose of Counter is already hinted at.

An interface for the class Counter  

What does an interface look like that describes a class? Interface CounterStatic describes the class Counter itself (not its instances):

interface CounterStatic {
  createZero(): CounterInstance,
  new(value: number): CounterInstance,
}
interface CounterInstance {
  value: number;
  increment(): void;
}

We can see that Counter should have a static method .createZero() and be a constructor function with a parameter value that produces objects of type CounterInstance.

In the following code, we use the interface CounterStatic to constrain the class Counter2:

const Counter2: CounterStatic = class extends Object {
  static createZero() {
    return new Counter2(0);
  }
  /** Bonus method */
  static createOne() {
    return new Counter2(1);
  }
  value: number;
  constructor(value: number) {
    super();
    this.value = value;
  }
  increment() {
    this.value++;
  }
  /** Bonus method */
  decrement() {
    this.value--;
  }
}

Counter2 doesn’t need implements CounterInstance because CounterStatic already demands that its instances match that interface.

One significant downside of this approach is:

  • Statically, Counter2 only has the features described by CounterStatic.
  • Statically, instances of Counter2 only have the features described by CounterInstance.

The following code demonstrates this issue:

// @ts-ignore: Property 'createOne' does not exist on type 'CounterStatic'. (2339)
const c = Counter2.createOne();

// @ts-ignore: Property 'decrement' does not exist on type 'CounterInstance'. (2339)
new Counter2(0).decrement();

As a work-around, we can perform a static check later (line A) that enforces Counter2 being a subtype of CounterStatic (vs. it having exactly the type signature specified by that interface).

class Counter2 extends Object {
  static createZero() {
    return new Counter2(0);
  }
  /** Bonus method */
  static createOne() {
    return new Counter2(1);
  }
  value: number;
  constructor(value: number) {
    super();
    this.value = value;
  }
  increment() {
    this.value++;
  }
  /** Bonus method */
  decrement() {
    this.value--;
  }
}

const staticCheck: CounterStatic = Counter2; // (A)

const c = Counter2.createOne(); // OK
new Counter2(0).decrement(); // OK

One benefit of this approach is that we can again use a class declaration (vs. a class expression) which has slightly nicer syntax.

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) {
    if (typeof json !== 'string') {
      throw new TypeError(json);
    }
    return new Person(json);
  }
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  toJson() {
    return this.name;
  }
}
const staticCheck: JsonStatic = Person;

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

Types for specific classes or their instances  

Consider the following class:

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

What types can we use for its instances and for the class itself?

Types for instances of specific classes  

We can use the static type Color for instances of Color:

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

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.

Types for specific classes  

What is the type for class Point itself? We have to use the typeof operator (line A):

class Point {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

function createInstance(TheClass: typeof Point, x: number, y: number) { // (A)
  return new TheClass(x, y);
}
// %inferred-type: Point
const point = createInstance(Point, 3, 6);
assert.ok(point instanceof Point);

We could have also used the following types for TheClass:

TheClass: { new(): Point }
TheClass: new () => Point
  • The former emphasizes that TheClass is an object.
  • The latter emphasizes that TheClass is a function.

Further reading