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

18 Types for classes as values



In this chapter, we explore classes as values:

18.1 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 createPoint(PointClass: ???, x: number, y: number) {
  return new PointClass(x, y);
}

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

18.2 The type operator typeof

In §7.7 “The two language levels: dynamic vs. static”, we explored the two language levels of TypeScript:

The class Point creates two things:

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

Instead, we need to use the type operator typeof (another bit of TypeScript syntax that also exists in JavaScript). typeof v stands for the type of the dynamic(!) value v.

function createPoint(PointClass: typeof Point, x: number, y: number) { // (A)
  return new PointClass(x, y);
}

// %inferred-type: Point
const point = createPoint(Point, 3, 6);
assert.ok(point instanceof Point);

18.2.1 Constructor type literals

A constructor type literal is a function type literal with a prefixed new (line A). The prefix indicates that PointClass is a function that must be invoked via new.

function createPoint(
  PointClass: new (x: number, y: number) => Point, // (A)
  x: number, y: number
) {
  return new PointClass(x, y);
}

18.2.2 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, PointClass has an object literal type with a construct signature:

function createPoint(
  PointClass: {new (x: number, y: number): Point},
  x: number, y: number
) {
  return new PointClass(x, y);
}

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

Class<T> is a type for classes whose instances match type T.

18.3.1 Example: creating instances

Class<T> enables us to write a generic version of createPoint():

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

createInstance() is used as follows:

class Person {
  constructor(public name: string) {}
}

// %inferred-type: Person
const jane = createInstance(Person, 'Jane');

createInstance() is the new operator, implemented via a function.

18.3.2 Example: casting with runtime checks

We can use Class<T> to implement casting:

function cast<T>(AnyClass: Class<T>, obj: any): T {
  if (! (obj instanceof AnyClass)) {
    throw new Error(`Not an instance of ${AnyClass.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);
}

18.3.3 Example: Maps that are type-safe at runtime

One use case for Class<T> and cast() is 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-expect-error: Argument of type '"abc"' is not assignable
  // to parameter of type 'Date'.
  () => map.set(Date, 'abc'));

18.3.4 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-expect-error: 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).

This is a workaround:

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

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

Downsides of this approach: