For example, I\'ve made a JavaScript library called lowclass and I\'m wondering how to make it work in the TypeScript type system.
The library lets us define a class
Ok so there are several problems we need to fix for this to work in a similar way to Typescript classes. Before we begin, I do all of the coding below in Typescript strict
mode, some typing behavior will not work without it, we can identify the specific options needed if you are interested in the solution.
In typescript classes hold a special place in that they represent both a value (the constructor function is a Javascript value) and a type. The const
you define only represents the value (the constructor). To have the type for Dog
for example we need to explicitly define the instance type of Dog
to have it usable later:
const Dog = /* ... */
type Dog = InstanceType
const smallDog: Dog = new Dog('small') // We can now type a variable or a field
The second problem is that constructor
is a simple function, not a constructor function and typescript will not let us call a new
on a simple function (at least not in strict mode). To fix this we can use a conditional type to map between the constructor and the original function. The approach is similar to here but I'm going to write it for just a few parameters to keep things simple, you can add more:
type IsValidArg = T extends object ? keyof T extends never ? false : true : true;
type FunctionToConstructor =
T extends (a: infer A, b: infer B) => void ?
IsValidArg extends true ? new (p1: A, p2: B) => TReturn :
IsValidArg extends true ? new (p1: A) => TReturn :
new () => TReturn :
never;
With the type above we can now create the simple Class
function that will take in the object literal and build a type that looks like the declared class. If here is no constructor
field, we will asume an empty constructor, and we must remove the constructor
from the type returned by the new constructor function we will return, we can do this with Pick
. We will also keep a field __original
to have the original type of the object literal which will be useful later:
function Class(name: string, members: T): FunctionToConstructor, Pick>> & { __original: T }
const Animal = Class('Animal', {
sound: '', // class field
constructor(sound: string) {
this.sound = sound;
},
makeSound() { console.log(this.sound) // this typed correctly }
})
In the Animal
declaration above, this
is typed correctly in the methods of the type, this is good and works great for object literals. For object literals this
will have the type of the curent object in functions defined in the object literal. The problem is that we need to specify the type of this
when extending an existing type, as this
will have the members of the current object literal plus the members of the base type. Fortunately typescript lets us do this using ThisType
a marker type used by the compiler and described here
Now using contextual this, we can create the extends
functionality, the only problem to solve is we need to see if the derived class has it's own constructor or we can use the base constructor, replacing the instance type with the new type.
type ReplaceCtorReturn =
T extends new (a: infer A, b: infer B) => void ?
IsValidArg extends true ? new (p1: A, p2: B) => TReturn :
IsValidArg extends true ? new (p1: A) => TReturn :
new () => TReturn :
never;
function Class(name: string): {
extends(base: TBase, members: (b: { Super : (t: any) => TBase['__original'] }) => T & ThisType>):
T extends { constructor: infer TCtor } ?
FunctionToConstructor, InstanceType & Pick>>
:
ReplaceCtorReturn & Pick>>
}
Putting it all together:
type IsValidArg = T extends object ? keyof T extends never ? false : true : true;
type FunctionToConstructor =
T extends (a: infer A, b: infer B) => void ?
IsValidArg extends true ? new (p1: A, p2: B) => TReturn :
IsValidArg extends true ? new (p1: A) => TReturn :
new () => TReturn :
never;
type ReplaceCtorReturn =
T extends new (a: infer A, b: infer B) => void ?
IsValidArg extends true ? new (p1: A, p2: B) => TReturn :
IsValidArg extends true ? new (p1: A) => TReturn :
new () => TReturn :
never;
type ConstructorOrDefault = T extends { constructor: infer TCtor } ? TCtor : () => void;
function Class(name: string): {
extends(base: TBase, members: (b: { Super: (t: any) => TBase['__original'] }) => T & ThisType>):
T extends { constructor: infer TCtor } ?
FunctionToConstructor, InstanceType & Pick>>
:
ReplaceCtorReturn & Pick>>
}
function Class(name: string, members: T & ThisType): FunctionToConstructor, Pick>> & { __original: T }
function Class(): any {
return null as any;
}
const Animal = Class('Animal', {
sound: '',
constructor(sound: string) {
this.sound = sound;
},
makeSound() { console.log(this.sound) }
})
new Animal('').makeSound();
const Dog = Class('Dog').extends(Animal, ({ Super }) => ({
constructor(size: 'small' | 'big') {
if (size === 'small')
Super(this).constructor('woof')
if (size === 'big')
Super(this).constructor('WOOF')
},
makeSound(d: number) { console.log(this.sound) },
bark() { this.makeSound() },
other() {
this.bark();
}
}))
type Dog = InstanceType
const smallDog: Dog = new Dog('small')
smallDog.bark() // "woof"
const bigDog = new Dog('big')
bigDog.bark() // "WOOF"
bigDog.bark();
bigDog.makeSound();
Hope this helps, let me know if I can help with anything more :)
Playground link