In this chapter, we explore classes as values:
Consider the following class:
class Point {: number;
x: number;
yconstructor(x: number, y: number) {
.x = x;
this.y = y;
this
} }
This function accepts a class and creates an instance of it:
function createPoint(PointClass: ???, x: number, y: number) {
new PointClass(x, y);
return }
What type should we use for the parameter PointClass
if we want it to be Point
or a subclass?
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:
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
(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)
new PointClass(x, y);
return
}
// %inferred-type: Point
= createPoint(Point, 3, 6);
const point .ok(point instanceof Point); assert
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(
: new (x: number, y: number) => Point, // (A)
PointClass: number, y: number
x
) {new PointClass(x, y);
return }
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(
: {new (x: number, y: number): Point},
PointClass: number, y: number
x
) {new PointClass(x, y);
return }
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:
<T> {
interface Classnew(...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>(AnyClass: Class<T>, ...args: any[]): T {
new AnyClass(...args);
return }
createInstance()
is used as follows:
class Person {constructor(public name: string) {}
}
// %inferred-type: Person
= createInstance(Person, 'Jane'); const jane
createInstance()
is the new
operator, implemented via a function.
We can use Class<T>
to implement casting:
function cast<T>(AnyClass: Class<T>, obj: any): T {
if (! (obj instanceof AnyClass)) {
new Error(`Not an instance of ${AnyClass.name}: ${obj}`)
throw
};
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
= JSON.parse(jsonObjectStr);
const parsed cast(Object, parsed);
return }
One use case for Class<T>
and cast()
is type-safe Maps:
class TypeSafeMap {= new Map<any, any>();
#data get<T>(key: Class<T>) {
= this.#data.get(key);
const value cast(key, value);
return
}set<T>(key: Class<T>, value: T): this {
cast(key, value); // runtime check
.#data.set(key, value);
this;
return this
}has(key: any) {
.#data.has(key);
return this
} }
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:
= new TypeSafeMap();
const map
.set(RegExp, /abc/);
map
// %inferred-type: RegExp
= map.get(RegExp);
const re
// Static and dynamic error!
.throws(
assert// @ts-expect-error: Argument of type '"abc"' is not assignable
// to parameter of type 'Date'.
=> map.set(Date, 'abc')); ()
Class<T>
does not match abstract classesWe 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)
: Array<Class<Shape>> = [Circle, Shape]; const shapeClasses1
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};
: Array<Class2<Shape>> = [Circle, Shape]; const shapeClasses2
Downsides of this approach:
instanceof
checks (as right-hand-side operands).