1

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 ?

sshmaxime
  • 499
  • 5
  • 15
  • Does [this approach](https://tsplay.dev/wOLAMN) meet your needs? If so I could write up an answer explaining; if not, what am I missing? – jcalz Feb 15 '23 at 16:01
  • @jcalz Could you, if you don't mind, please modify the tsplay and use my types, such as FactoryContructor and ContractBuilder. I did super simply my code here and it's hard to do the parallel from your code to mine – sshmaxime Feb 15 '23 at 16:18
  • My issue reside in the fact that in the build function, should be somehow inside the part. It's really hard to explain, sorry – sshmaxime Feb 15 '23 at 16:22
  • Like [this](https://tsplay.dev/w1E7lw)? It's a bit weird to be beholden to types that aren't motivated in the example code, but if it helps you evaluate it, there you go. – jcalz Feb 15 '23 at 16:22
  • Ok, this is the way to go. Can you write an answer please ? I'll validate it. Just make sure to keep all my variables, functions and everything in my code above and make sure it's compilable/working on the get go because I cannot make it work with your declare const stuff and cannot wrap my around it to make it work. Thank you so much ! – sshmaxime Feb 15 '23 at 16:57
  • Like [this](https://tsplay.dev/we6BeN)? I hope I don't have to bring back the unconventional kebab case names. – jcalz Feb 15 '23 at 17:07

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360