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:
strategyMap
(i.e. object whose entries are functions that accept the same set of parameters and that return string
).strategies
has not Record<any, any>
type.cascadeStrats.use
he 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, and T
, the mapping from strategy keys to the return type of the corresponding strategy. Given those types, then strategies
would be of type
type Strategies<A extends any[], T> =
{ [K in keyof T]: (...args: A) => T[K] }
which is a mapped type converting each member of T
into a function that returns that member.
Here's how I'd chance CascadeStrategies
:
class CascadeStrategies<A extends any[], T extends object> {
private strategies!: Strategies<A, T>
constructor(strategyMap:
Record<string, (...args: A) => any> &
Strategies<A, T>
) {
this.registerStrategies(strategyMap);
}
private registerStrategies(strategyMap: Strategies<A, T>) {
this.strategies = strategyMap;
}
use<K extends keyof T>(
strategies: K[],
...args: A
) {
return this.strategies[strategies[0]](...args); // okay
}
}
That compiles without error. The important part here is what's going on inside use()
. Now that function is generic in K
, a key of T
. The return type of use()
is inferred T[K]
, as desired.
Note that in order for this to work, we need TypeScript to infer both A
and T
when you write new CascadeStrategies(myMap)
. Inference can be tricky. My approach was to make the constructor parameter be of type Record<string, (...args: A) => any> & Strategies<A, T>
. That's an intersection, where each piece helps infer a different type argument. The Record<string, (...args: A) => any>
type allows A
to be inferred, since it can happen before TypeScript knows anything about T
. And then Strategies<A, T>
allows T
to be inferred from the return types of the methods. It's always safe to widen an intersection X & Y
to one of its members Y
, so we can dispose of the complicated intersection and just treat strategyMap
as type Strategies<A, T>
.
Let's test it out:
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 cascadeStrats: CascadeStrategies<
[arg1: number, arg2: string],
{ test: number; otherTest: string; thirdTest: null; }
>
*/
const shouldBeNumber = cascadeStrats.use(["test"], 0, "");
// ^? const shouldBeNumber: number
const shouldBeString = cascadeStrats.use(["otherTest"], 0, "");
// ^? const shouldBeString: string
const shouldBeNull = cascadeStrats.use(["thirdTest"], 0, "");
// ^? const shouldBeNull: null
const shouldBeStringOrNumber = cascadeStrats.use(["test", "otherTest"], 0, "")
// ^? const shouldBeStringOrNumber: string | number
cascadeStrats.use(["test"], "oops", "abc"); // error!
// -----------------------> ~~~~~~
// Argument of type 'string' is not assignable to parameter of type 'number'.
Looks good. T
is inferred properly, so that shouldBeXXX
are all of the expected types, and A
is also inferred properly, so that the compiler notices if you pass in the wrong parameter type (as shown in the last line above).