How can we type a class factory that generates a class given an object literal?

后端 未结 1 1937
[愿得一人]
[愿得一人] 2020-12-22 01:46

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

相关标签:
1条回答
  • 2020-12-22 02:22

    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.

    Type and value

    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<typeof Dog>
    const smallDog: Dog = new Dog('small') // We can now type a variable or a field
    

    Function to constructor

    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> = T extends object ? keyof T extends never ? false : true : true;
    
    type FunctionToConstructor<T, TReturn> =
        T extends (a: infer A, b: infer B) => void ?
            IsValidArg<B> extends true ? new (p1: A, p2: B) => TReturn :
            IsValidArg<A> extends true ? new (p1: A) => TReturn :
            new () => TReturn :
        never;
    

    Building the type

    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<T, Exclude<keyof T, 'constructor'>>. We will also keep a field __original to have the original type of the object literal which will be useful later:

    function Class<T>(name: string, members: T): FunctionToConstructor<ConstructorOrDefault<T>, Pick<T, Exclude<keyof T, 'constructor'>>> & { __original: T  }
    
    
    const Animal = Class('Animal', {
        sound: '', // class field
        constructor(sound: string) {
            this.sound = sound;
        },
        makeSound() { console.log(this.sound) // this typed correctly }
    })
    

    This type in methods

    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<T> a marker type used by the compiler and described here

    Creating extends

    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, TReturn> =
        T extends new (a: infer A, b: infer B) => void ?
            IsValidArg<B> extends true ? new (p1: A, p2: B) => TReturn :
            IsValidArg<A> extends true ? new (p1: A) => TReturn :
            new () => TReturn :
        never;
    function Class(name: string): {
        extends<TBase extends {
            new(...args: any[]): any,
            __original: any
        }, T>(base: TBase, members: (b: { Super : (t: any) => TBase['__original'] }) => T & ThisType<T & InstanceType<TBase>>):
            T extends { constructor: infer TCtor } ?
            FunctionToConstructor<ConstructorOrDefault<T>, InstanceType<TBase> & Pick<T, Exclude<keyof T, 'constructor'>>>
            :
            ReplaceCtorReturn<TBase, InstanceType<TBase> & Pick<T, Exclude<keyof T, 'constructor'>>>
    }
    

    Putting it all together:

    type IsValidArg<T> = T extends object ? keyof T extends never ? false : true : true;
    
    type FunctionToConstructor<T, TReturn> =
        T extends (a: infer A, b: infer B) => void ?
        IsValidArg<B> extends true ? new (p1: A, p2: B) => TReturn :
        IsValidArg<A> extends true ? new (p1: A) => TReturn :
        new () => TReturn :
        never;
    
    type ReplaceCtorReturn<T, TReturn> =
        T extends new (a: infer A, b: infer B) => void ?
        IsValidArg<B> extends true ? new (p1: A, p2: B) => TReturn :
        IsValidArg<A> extends true ? new (p1: A) => TReturn :
        new () => TReturn :
        never;
    
    type ConstructorOrDefault<T> = T extends { constructor: infer TCtor } ? TCtor : () => void;
    
    function Class(name: string): {
        extends<TBase extends {
            new(...args: any[]): any,
            __original: any
        }, T>(base: TBase, members: (b: { Super: (t: any) => TBase['__original'] }) => T & ThisType<T & InstanceType<TBase>>):
            T extends { constructor: infer TCtor } ?
            FunctionToConstructor<ConstructorOrDefault<T>, InstanceType<TBase> & Pick<T, Exclude<keyof T, 'constructor'>>>
            :
            ReplaceCtorReturn<TBase, InstanceType<TBase> & Pick<T, Exclude<keyof T, 'constructor'>>>
    }
    function Class<T>(name: string, members: T & ThisType<T>): FunctionToConstructor<ConstructorOrDefault<T>, Pick<T, Exclude<keyof T, 'constructor'>>> & { __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<typeof Dog>
    
    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

    0 讨论(0)
提交回复
热议问题