When working with classes, there are two types to consider: the type of the static side and the type of the instance side.
The most common is of course the instance side, and things are working like expected :
interface HasName {
name: string
}
class Person implements HasName {
name!: string
birthDate!: Date
}
In the example above, all the fields are describing an instance of a Person
, that is defined partially with the interface HasName
.
Then, we might add some convenient static methods :
class Person implements HasName {
name!: string
birthDate!: Date
static async load(name: string): Promise<Person> {
// get data and create a new Person someway
}
static async save(person: Person) {
// save the Person someway
}
}
// try it :
const bob = await Person.load('Bob')
bob.birthDate.getFullYear();
At this point of the design of such outstanding application, we guess that some other classes will follow the same pattern for loading and saving data, therefore we create an interface for that purpose :
interface Storable<Type, Key> {
load(key: Key): Promise<Type>
save(item: Type): Promise<void>
}
Since the static
keyword can't be used on interfaces, I just removed it, but how to enforce my Person
class to implement those methods as static ?
Language evolution proposal
There has been some discussions on that topic, but my preferred solution would be :
// unfortunately, this is not allowed by Typescript :
class Person implements HasName, static Storable<Person, string> {
name!: string
birthDate!: Date
static async load(name: string): Promise<Person> {
// get data and create a new Person someway
}
static async save(person: Person) {
// save the Person someway
}
}
Unlike other proposals, it has the advantage to not restrict the interface to classes and keep the intend of Typescript of being a structural type system.
But that syntax doesn't exist : we must use the possibilities of the language.
Understanding class type duality
Let's recall that 2 types exist around each class :
- the type of the static side,
- the type of the instance side.
Before showing acceptable patterns, we must understand what is the type of the static side of a class. For that purpose, let's examine how they are managed on predefined classes, say let's have a look at String
:
interface String {
charAt(pos: number): string;
charCodeAt(index: number): number;
// etc, all instance methods
}
interface StringConstructor {
new(value?: any): String;
(value?: any): string;
readonly prototype: String;
fromCharCode(...codes: number[]): string;
}
declare var String: StringConstructor;
So, String
instances are fully defined by the interface definition, and at the end, the same String
stuff has a type, the StringConstructor
interface.
Strictly speaking, the name endorsed is not the best choice, because the string constructor is just the function
new(value?: any): String;
: all other definitions of that interface are static definitions, therefore the nameStringStatic
would have certainly been a better choice ; but let's refrain from rewriting history...
In fact, the last line declare var String: StringConstructor
is used to enforce the built-in variable String
to be of the type StringConstructor
. But String
as a variable doesn't refer to an instance but to the class.
Therefore, we might apply the same recipe for our own classes : what is needed is just a typed variable to which one can assign the class, it will enforce the class to honour that type.
Pattern 1 : class expression
Let's try it on our type Person
; first we can make some observations if we assign that class to some variable (actually, a constant !) :
In our preferred IDE, we see that person
has the type typeof Person
: this is the static side of the class Person
. Unfortunately, unlike with the built-in String, we can't call the constant Person
because that symbol already exist in the value space for the class itself, therefore we call it person
.
Hopefully, we can use class expressions:
interface PersonInterface extends HasName {
birthDate: Date
}
interface PersonStatic extends Storable<PersonInterface, string> {
new(): PersonInterface
}
const Person: PersonStatic = class Person implements HasName {
name!: string
birthDate!: Date
static async load(name: string): Promise<PersonInterface> {
// get data and create a new Person someway
}
static async save(person: Person) {
// save the Person someway
}
}
Ouch ! There are too much disadvantages with this pattern :
- we can't apply a decorator on the class
- generics are left out (if we had
Person<Foo>
?) - lots of boilerplate code are necessary
Let's take a closer look at the last issue. Why do we have this new interface PersonInterface
? This is because we lost the type typeof Person
of the class : if we use Storable<typeof Person>
, it would refer the type of the constant Person
which is not what we want. On the other hand, Storable<Person>
is not usable since Person
is a constant. Do you follow ? Well... we need that new interface PersonInterface
.
In fact, we have to fix the things lost when using the class as an expression.
Pattern 2 : static field of itself
One better solution, is to have a standalone typed variable that refers the class. But wait, where is the preferred place to define such a variable ? In the class itself as a static variable of course ! But wait, could we define an interface for such classes ? Sure we can :
interface HasStatic<Type> {
class: HasStatic<Type>
}
Then, we can define a type for the static side of our class :
type PersonStatic = Storable<Person, string> & HasStatic<Person>;
class Person implements HasName {
static class: PersonStatic = Person; // 👈
name!: string
birthDate!: Date
static async load(name: string): Promise<Person> {
// get data and create a new Person someway
}
static async save(person: Person) {
// save the Person someway
}
}
The thing that enforce our static methods is due to the line static class: PersonStatic = Person
: it has the type PersonStatic
and has the value Person
, which is the class itself.
I personally like that solution because it is in the spirit of the language evolution that I suggest before.
Pattern 3 : static block with field of itself
The following pattern is a variant of the previous one :
class Person implements HasName {
static {
const clazz: Storable<Person, string> = Person;
}
name!: string
birthDate!: Date
static async load(name: string): Promise<Person> {
// get data and create a new Person someway
}
static async save(person: Person) {
// save the Person someway
}
}
It has the advantage of being more straightforward and to drop the clazz
constant after being set. Notice that class
couldn't be used as the constant name; hence clazz
.
Pattern 4 : class decorator
Another solution is to call a function that takes as argument the static definition of the class. The best way to apply that function on our class is by using the semantic of the decorator.
Here is the decorator factory, that does nothing and returns nothing, which means that the decorated class is not altered ; its role is to enforce its argument (the static side of our class) to be of the type given :
function HasStatic<Type>() {
return (target: Type) => {};
}
And this is how it has to be apply to our class :
@HasStatic<Storable<Person, string>>()
class Person implements HasName {
name!: string
birthDate!: Date
static async load(name: string): Promise<Person> {
// get data and create a new Person someway
}
static async save(person: Person) {
// save the Person someway
}
}
Notice that we used a decorator factory instead of a decorator, because we wouldn't be able to use the latter like this : @HasStatic<Storable<Person, string>>
.
Thank you for reading, Typescript Padawan !
Top comments (0)