HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

23 Types for classes as values

In this chapter, we explore classes as values:

23.1 Question: Which type for a class as a value?

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

What type C should we use for the parameter PointClass if we want the function to return an instance of Point?

23.2 Answer: types for classes as values

23.2.1 The type operator typeof

In “TypeScript’s two language levels” (§4.4), 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 (which has the same name as a JavaScript operator). typeof v stands for the type of the value v.

Let’s omit the return type of createPoint() and see what TypeScript infers:

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

const point = createPoint(Point, 3, 6);
assertType<Point>(point); // (A)
assert.ok(point instanceof Point);

As expected, createPoint() creates values of type Point (line A).

23.2.2 Constructor type literals

A constructor type literal is a literal for constructor types: new followed by a function type literal (line A):

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

The prefix new of its type indicates that PointClass is a function that must be invoked via new.

Constructor type literals are quite versatile – e.g., we can demand that a constructor function (such as a class):

function f(
  ClassThatImplementsInterf: new () => Interf
) {}

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

23.3 A generic type for constructors: 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.

23.3.1 Example: creating instances

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

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

createInstance() is used as follows:

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

const jane = createInstance(Person, 'Jane');
assertType<Person>(jane);

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

23.3.2 Example: type-narrowing via instanceof

In line A, instanceof narrows the type of arg: Before, it is unknown. After, it is T.

function isInstance<T>(TheClass: Class<T>, arg: unknown): boolean {
  type _ = Assert<Equal<
    typeof arg, unknown
  >>;
  if (arg instanceof TheClass) { // (A)
    type _ = Assert<Equal<
      typeof arg, T
    >>;
    return true;
  }
  return false;
}

23.3.3 Example: casting with runtime checks

We can use Class<T> to implement casting:

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

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 {
  const parsed = JSON.parse(jsonObjectStr);
  type _ = Assert<Equal<
    typeof parsed, any
  >>;
  return cast(Object, parsed);
}

23.3.4 Example: an assertion function

We can turn function cast() from the previous subsection into an assertion function:

/**
 * After invoking this function, the inferred type of `value` is `T`.
 */
export function throwIfNotInstance<T>(
  TheClass: Class<T>, value: unknown
): asserts value is T { // (A)
  if (!(value instanceof TheClass)) {
    throw new Error(`Not an instance of ${TheClass}: ${value}`);
  }
}

The return type (line A) makes throwIfNotInstance() an assertion function that narrows types:

const parsed = JSON.parse('[1, 2]');
type _1 = Assert<Equal<
  typeof parsed, any
>>;
throwIfNotInstance(Array, parsed);
type _2 = Assert<Equal<
  typeof parsed, Array<unknown>
>>;

23.3.5 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<unknown, unknown>();
  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: unknown) {
    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/);

const re = map.get(RegExp);
assertType<RegExp>(re);

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

23.3.6 Pitfall: Class<T> does not match abstract classes

Consider the following classes:

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

Class<T> does not match the abstract class Shape (last line):

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

// @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.
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.

If we want to Class<T> to match both abstract and concrete classes, we can use an abstract construct signature:

type Class<T> = abstract new (...args: any[]) => T;
const shapeClasses: Array<Class<Shape>> = [Circle, Shape];

There is once caveat – this type cannot be new-invoked:

function createInstance<T>(TheClass: Class<T>, ...args: unknown[]): T {
  // @ts-expect-error: Cannot create an instance of an abstract class.
  return new TheClass(...args);
}

However, the new Class<T> works well for all other use cases, including instanceof:

function isInstance<T>(TheClass: Class<T>, arg: unknown): boolean {
  type _ = Assert<Equal<
    typeof arg, unknown
  >>;
  if (arg instanceof TheClass) {
    type _ = Assert<Equal<
      typeof arg, T
    >>;
    return true;
  }
  return false;
}

Therefore, we can rename the old type for classes to NewableClass<T> – in case we need a class to be new-invokable:

type NewableClass<T> = new (...args: any[]) => T;
function createInstance<T>(TheClass: NewableClass<T>, ...args: unknown[]): T {
  return new TheClass(...args);
}