In this blog post, 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 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?
typeof
TypeScript clearly separates two kinds of syntax:
The class Point
creates two things:
Point
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);
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);
}
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);
}
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>
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');
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);
}
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'));
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:
instanceof
checks.