I have recently found myself needing a type for class constructors that is at once generic and tight enough to ensure a genuine constructor. This is useful in situations where you must handle a variety of classes - those that come from other libraries or applications that you cannot control.
When writing your own dependency injection container or some other generalised library you cannot know what class constructors might be passed in. The code just needs to know that calling the constructor will lead to an instance.
I’ve settled on a type that I use for this situation. Whilst the type itself will land up being rather small, and some might say simple, it is, nevertheless not particularly obvious.
An example class constructor we might want to pass to other functions could be something like
this little Author
class definition.
class Author {
public readonly age: number = NaN;
public readonly email: string = "";
public readonly name: string = "";
}
When creating API endpoints it is common to accept a JSON string in the request body that needs to be validated and, ideally where TypeScript is involved, correctly typed. To facilitate this we might have a function that takes a JSON string and converts it into an instance of a predetermined class.
This code is for demonstration and not production ready, but you could imagine it handling requests for a REST API.
/**
* Using a given JSON string construct and populate an instance of the
* supplied class constructor
* @param source JSON request payload string that the API receives
* @param destinationConstructor a class constructor
*/
const json2Instance = (source: string, destinationConstructor: any) =>
Object.assign(new destinationConstructor(), JSON.parse(source));
const simon = json2Instance('{"name":"simon"}', Author);
This looks like it will work nicely, but in practice by using the any
type on the
destinationConstructor
the types have been broken. This prevents type checking from
working correctly, which also means that auto hinting will no longer work in developer’s
IDEs or editors. So, we need to come up with a type for it so that json2Instance()
allows
the type signatures to flow through.
Types given as any
effectively block all benefits of using TypeScript in the first
place - there is a place for them, but that is another article entirely.
Looking at the types available in lib.es5.d.ts
from the TypeScript language
source code shows us what a constructor type could look like. There are types for all the
native JavaScript constructors such as Number
, String
,
Function
and Object
.
Both the Function
and Object
constructor types have additional properties that are
possibly undesirable, so instead of using them directly or extending them we’ll create
our own.
The most basic of constructor types could be defined as a function that returns an Object
,
but this is not quite what we are after as you might see.
type Constructor = new () => Object;
const json2Instance = (source: string, destinationConstructor: Constructor) =>
Object.assign(new destinationConstructor(), JSON.parse(source));
Unfortunately, we’re still losing the type - we know it’s an Author
, but this constructor
type is telling TypeScript that it is a standard or plain old JavaScript Object
. To tighten this
up it is necessary to introduce generic types.
Before we move onto that though - a quick word on constructors that take arguments (args
in the
example code). To handle constructor functions that take arguments we can make use of the spread
operator in the constructor type.
class AuthorWithConstructor extends Author {
public readonly greeting!: string;
constructor(name: string = "") {
this.greeting = `Top of the muffin to you, ${name}`;
}
}
type Constructor = new (...args: any[]) => Object;
This Constructor
type is still indicating that the returned value is of type Object
, which as
we discovered before is breaking the typings for the json2Instance()
function. Using TypeScript’s
generics features it is possible to correct this behaviour.
type Constructor<T> = new (...args: any[]) => T;
By passing in the type (T
) as a generic type argument it is possible to state that the
return value of the constructor is of type T
. To use this new type there are a couple of
changes to make to the json2Instance()
function to allow for the generic to be specified.
const json2Instance = <T>(
source: string,
destinationConstructor: Constructor<T>,
): T => Object.assign(new destinationConstructor(), JSON.parse(source));
When called the type (Author
) now flows through as the generic T
type.
const simon = json2Instance('{"name":"simon"}', Author);
console.log({ age: simon.age, nextYear: simon.age + 1 });
// no type errors because it knows age is number in the addition
// also in your IDE/editor you'll now get code completion/suggestions where you type
// the instance name `simon` and get a list of possible properties:
// simon.
// |--> age
// |--> email
// |--> name
So, we have solved the problem where the type of the constructor (Author
) is known. However, it
is not always possible or desirable to know the type. Think of defining other types or interfaces that
have a constructor as a property.
A limited example of this in action might be to have a list of class constructors.
type ControllerList = Constructor[];
We do not know the class type of the constructors in the list and it is not necessary to know for our
calling code. It just needs to know it can create an instance. By providing a default for the type
argument (T
) of {}
we allow implementing types avoid providing a type argument that they cannot
know.
type Constructor<T = {}> = new (...args: any[]) => T;
By default the type will be a constructor that returns an object, but as before if you
specify the type argument T
then it will use the given type.
It is possible to tighten up our definition a little further using the extends
keyword so that any
T
must have an object type - as all constructors do.
type Constructor<T extends {} = {}> = new (...args: any[]) => T;
And, there you have it. A constructor type that is at once flexible and restrictive.