typescripttyped

Extract type for each field of an object


In typescript, given an object represented as a record of derived class, how do I extract the proper underlying class ?

Let me give you an exemple of my problem:

abstract class factory {
    abstract deploy(...[]): any;
}

class a_factory extends factory {
    deploy = (a: number, b: string) => {};
}

class b_factory extends factory {
    deploy = (a: {}, b: number) => {};
}

const obj = {
    a: a_factory,
    b: b_factory
};

export type Object<F extends factory> = ReturnType<F['deploy']>;
export interface ContractBuilder<F extends factory> {
    deploy(...args: Parameters<F['deploy']>): Object<F>;
}
export type FactoryConstructor<F extends factory> = {
    new (): F;
};

const doSome = (any: any) => {};

const build = <K extends factory, F extends Record<string, FactoryConstructor<K>>>(contracts: F) => {
    const a: { [contractName: string]: any } = {};

    for (const x in contracts) {
        a[x] = doSome(contracts[x] as any);
    }

    type MySuperType<T extends F> = {
        [P in keyof T]: ContractBuilder<K>;
    };

    return a as MySuperType<F>;
};

const MyTestObj = build(obj);

MyTestObj.a.deploy() //  This is not typed to a_factory
MyTestObj.b.deploy() //  This is not typed to b_factory

As you can see, the final object doesn't infer the correct type for each of his member.

How could I achieve that ?


Solution

  • I'd say the main problem here is that your build() function has no inference site for the generic type parameter K. You might think that <K extends Factory, F extends Record<string, FactoryConstructor<K>>> means that K could be inferred from the constraint on F, but that's not how it works. There is an old closed suggestion to support this at microsoft/TypeScript#7234, but it was never implemented. Currently, the rule you should follow is: each type parameter in a generic function should appear in the function parameter types, as directly as possible. For a type parameter T, the best inference site is where there's a function parameter of type T (e.g., <T,>(x: T) => ...). Another good inference site is from a homomorphic mapped type (see What does "homomorphic mapped type" mean? ) (e.g., <T,>(x: {[K in keyof T]: ...T[K]...}) => ...).

    So here's a different way to write build():

    const build = <T extends Record<keyof T, Factory>>(
      contracts: { [K in keyof T]: FactoryConstructor<T[K]> }
    ) => {
      const a: { [contractName: string]: any } = {};
    
      for (const x in contracts) {
        a[x] = doSome(contracts[x] as any);
      }
      type MySuperType<T extends Record<keyof T, Factory>> = {
        [K in keyof T]: ContractBuilder<T[K]>;
      };
    
      return a as MySuperType<T>;
    };
    

    Here, there is only one type parameter, T, whose type is that of an object whose property values are all assignable to Factory. Then contracts is a homomorphic mapped type on T, where each property is a FactoryConstructor for the analogous property of T. So if contracts is of type {foo: FactoryConstructor<Foo>, bar: FactoryConstructor<Bar>}, then T will be inferred as {foo: Foo, bar: Bar}.

    The return type is MySuperType<T>, which also maps over T, this time turning each property into a ContractBuilder for the analogous property of T. So if T is {foo: Foo, bar: Bar}, then MySuperType<T> is {foo: ContractBuilder<Foo>, bar: ContractBuilder<Bar>}.


    Let's test it out:

    const MyTestObj = build(obj);
    
    MyTestObj.a;
    //(property) a: ContractBuilder<AFactory>
    MyTestObj.a.deploy(123, "456"); // okay
    MyTestObj.a.deploy({}, 789); // error!
    
    MyTestObj.b;
    //(property) b: ContractBuilder<BFactory>
    MyTestObj.b.deploy({}, 789); // okay
    MyTestObj.b.deploy(123, "456"); // error!
    

    Looks good!

    Playground link to code