Types for classes as values in TypeScript

[2020-04-15] dev, javascript, typescript
(Ad, please don’t block)
Warning: This blog post is outdated. Instead, read chapter “Types for classes as values” in “Tackling TypeScript”.

In this blog post, we explore classes as values:

  • What types should we use for such values?
  • What are the use cases for these types?

Types for specific classes  

Consider the following class:

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

This function accepts a class and creates an instance of it:

function createInstance(TheClass: ???, x: number, y: number) {
  return new TheClass(x, y);
}

What type should we use for the parameter TheClass if we want it to be Point or a subclass?

The type operator typeof   

TypeScript clearly separates two kinds of syntax:

  • Runtime (dynamic): plain JavaScript
    • Statements become code and may produce values as side effects (e.g. function declarations).
    • Expressions become values.
  • Compile time (static): TypeScript
    • Type expression become types.

The class Point creates two things:

  • The constructor function Point
  • The interface Point for instances of Point

Depending on where we mention Point, it therefore means different things. That’s why we can’t use the type Point for TheClass – it matches instances of class Point, not class Point itself.

We can fix this via the type operator typeof (another bit of static syntax that also exists dynamically). typeof v stands for the type of the dynamic(!) value v.

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

Constructor type literals  

A constructor type literal is a function type literal with a prefixed new. The prefix indicates that TheClass is a function that must be invoked via new.

function createInstance(
  TheClass: new (x: number, y: number) => Point,
  x: number, y: number
) {
  return new TheClass(x, y);
}

Object type literals with construct signatures  

Recall that members of interfaces and object literal types (OLTs) include method signatures and call signatures. Call signatures enable interfaces and OLTs to describe functions.

Similarly, construct signatures enable interfaces and OLTs to describe constructor functions. They look like call signatures with the added prefix new. In the next example, TheClass has an object literal type with a construct signature:

function createInstance(
  TheClass: {new (x: number, y: number): Point},
  x: number, y: number
) {
  return new TheClass(x, y);
}

A generic type for classes: Class<T>  

With the knowledge we have acquired, we can now create a generic type for classes as values – by introducing a type parameter T:

type Class<T> = new (...args: any[]) => T;

Instead of a type alias, we can also use an interface:

interface Class<T> {
  new(...args: any[]): T;
}

Example: creating instances  

Class<T> enables us to implement the new operator:

function newInstance<T>(TheClass: Class<T>, ...args: any[]): T {
  return new TheClass(...args);
}

newInstance() is used as follows:

class Person {
  constructor(public name: string) {}
}
const jane: Person = newInstance(Person, 'Jane');

Example: casting with runtime checks  

function cast<T>(TheClass: Class<T>, obj: any): T {
  if (! (obj instanceof TheClass)) {
    throw new Error(`Not an instance of ${TheClass.name}: ${obj}`)
  }
  return obj;
}

With cast(), we can change the type of a value to something more specific. This is also safe at runtime, because we both statically change the type and perform a dynamic check. The following code provides an example:

function parseObject(jsonObjectStr: string): Object {
  // %inferred-type: any
  const parsed = JSON.parse(jsonObjectStr);
  return cast(Object, parsed);
}

Example: Maps that are type-safe at runtime  

One use case for Class<T> and cast() are type-safe Maps:

class TypeSafeMap {
  #data = new Map<any, any>();
  get<T>(key: Class<T>) {
    const value = this.#data.get(key);
    return cast(key, value);
  }
  set<T>(key: Class<T>, value: T): this {
    cast(key, value); // runtime check
    this.#data.set(key, value);
    return this;
  }
  has(key: any) {
    return this.#data.has(key);
  }
}

The key of each entry in a TypeSafeMap is a class. That class determines the static type of the entry’s value and is also used for checks at runtime.

This is TypeSafeMap in action:

const map = new TypeSafeMap();

map.set(RegExp, /abc/);

// %inferred-type: RegExp
const re = map.get(RegExp);

// Static and dynamic error!
assert.throws(
  // @ts-ignore: Argument of type '"abc"' is not assignable
  // to parameter of type 'Date'.
  () => map.set(Date, 'abc'));

Pitfall: Class<T> does not match abstract classes  

We cannot use abstract classes when Class<T> is expected:

abstract class Shape {
}
class Circle extends Shape {
    // ···
}

// @ts-ignore: Type 'typeof Shape' is not assignable to type 'Class<Shape>'.
//   Cannot assign an abstract constructor type to a non-abstract constructor type. (2322)
const shapeClasses1: Array<Class<Shape>> = [Circle, Shape];

Why is that? The rationale is that constructor type literals and construct signatures should only be used for values that can actually be new-invoked (GitHub issue with more information).

We can fix this as follows:

type Class2<T> = Function & {prototype: T};

const shapeClasses2: Array<Class2<Shape>> = [Circle, Shape];

Downsides of this approach:

  • Slightly confusing.
  • Does not work for instanceof checks.