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 ?
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!