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:
PointPoint for instances of PointDepending 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.