In this chapter, we explore classes as values:
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
?
typeof
In “TypeScript’s two language levels” (§4.4), we explored the two language levels of TypeScript:
The class Point
creates two things:
Point
Point
for instances of Point
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).
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
) {}
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);
}
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
.
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.
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;
}
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);
}
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>
>>;
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')
);
Class<T>
does not match abstract classesConsider 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);
}