I am creating this class (playground link):
export class CascadeStrategies<
T extends Record<any, (...args: any[]) => unknown>
> {
private strategies: T = {} as T;
constructor(strategyMap: T) {
this.registerStrategies(strategyMap);
}
private registerStrategies(strategyMap: T) {
this.strategies = strategyMap;
}
use(
strategies: (keyof T)[],
...args: Parameters<T[keyof T]>
): ReturnType<T[keyof T]> {
return this.strategies[strategies[0]](...args);
}
}
The expected use of this class should be
const myMap = {
test: (arg1: number, arg2: string) => arg1,
otherTest: (arg1: number, arg2: string) => arg2,
thirdTest: (arg1: number, arg2: string) => null
}
const cascadeStrats = new CascadeStrategies(myMap);
const shouldBeNumber = cascadeStrats.use(["test"], 0, "");
const shouldBeString = cascadeStrats.use(["otherTest"], 0, "");
const shouldBeNull = cascadeStrats.use(["thirdTest"], 0, "");
I want T to be an object whose entries are functions that can accept the same set of parameters and returns a string, so I am using T extends Record<any, (...args: unknown[]) => string.
With this typing, this.strategies[strategies[0]](...args) has type unknown which is incompatible with the expected ReturnType<T[keyof T]>.
If I change the type of strategies from T to Record<any, any>, this.strategies[strategies[0]](...args) will have the correct type and is correctly inferenced when used. Even though strategies is just an internal variable and does not affect DX when using the class, I was wondering what I am missing here to achieve the desired result:
- Correct inference while the user defined
strategyMap(i.e. object whose entries are functions that accept the same set of parameters and that returnstring). strategieshas notRecord<any, any>type.- When the user use
cascadeStrats.usehe gets correct inferences in the arguments of the function and the returned type.
I think the most straightforward way to express this is to split your generic type parameter apart into two. You can have
A, the parameter list type common to all the strategies, andT, the mapping from strategy keys to the return type of the corresponding strategy. Given those types, thenstrategieswould be of typewhich is a mapped type converting each member of
Tinto a function that returns that member.Here's how I'd chance
CascadeStrategies:That compiles without error. The important part here is what's going on inside
use(). Now that function is generic inK, a key ofT. The return type ofuse()is inferredT[K], as desired.Note that in order for this to work, we need TypeScript to infer both
AandTwhen you writenew CascadeStrategies(myMap). Inference can be tricky. My approach was to make the constructor parameter be of typeRecord<string, (...args: A) => any> & Strategies<A, T>. That's an intersection, where each piece helps infer a different type argument. TheRecord<string, (...args: A) => any>type allowsAto be inferred, since it can happen before TypeScript knows anything aboutT. And thenStrategies<A, T>allowsTto be inferred from the return types of the methods. It's always safe to widen an intersectionX & Yto one of its membersY, so we can dispose of the complicated intersection and just treatstrategyMapas typeStrategies<A, T>.Let's test it out:
Looks good.
Tis inferred properly, so thatshouldBeXXXare all of the expected types, andAis also inferred properly, so that the compiler notices if you pass in the wrong parameter type (as shown in the last line above).Playground link to code