Reputation: 181
The particular TypeScript code receives from the back-end an array of Config type objects.
And for each Config object in the array, it needs to update / populate the params with the appropriate information, and after that do a set of calls, with the populated params.
I am having trouble with creating the appropriate types for this scenario. Here is some code of the current state of the system, and the problem I encounter.
There is an enum with all the available partners:
enum TheEnum {
Foo = 'foo',
Bar = 'bar',
}
For each partner we know the shape of the params they accept:
interface ParamMap {
[TheEnum.Foo]: {
readonly a: number,
b: string,
c?: boolean
},
[TheEnum.Bar]: {
z: number,
},
};
We have a map of functions, whose sole purpose is to populate the optional keys, or modify the required ones, that are not readonly:
type ParamGetterFn<T extends TheEnum, U = ParamMap[T]> = (params: U) => U;
type GetterMap = {
[Key in TheEnum]: ParamGetterFn<Key>;
}
const getterMap: GetterMap = {
[TheEnum.Foo]: (params) => {
return { ...params, b: 'adjusted value', c: false };
},
[TheEnum.Bar]: (params) => {
return { ...params, z: 456 };
},
};
The Config object is in the shape of:
interface Config<T extends TheEnum> {
key: T,
params: ParamMap[T],
}
so the key and the params that come from the back-end need to match.
The problem comes when I try to call all of these getters:
function caller(configArr: Config<TheEnum>[]) {
configArr.map((currentConfig) => {
const currentGetter = getterMap[currentConfig.key];
const newParams = currentGetter(currentConfig.params);
// do something with the new / populated params
})
}
I don't quite know how to type this scenario properly, as TypeScript reports:
Argument of type '{ readonly a: number; b: string; c?: boolean; } | { z: number; }' is not assignable to parameter of type '{ readonly a: number; b: string; c?: boolean; } & { z: number; }'. Type '{ readonly a: number; b: string; c?: boolean; }' is not assignable to type '{ readonly a: number; b: string; c?: boolean; } & { z: number; }'. Property 'z' is missing in type '{ readonly a: number; b: string; c?: boolean; }' but required in type '{ z: number; }'.
In my head, because the getters are receiving and returning the same type of object, and the Config type binds the params type and key, TypeScript should figure it out. Clearly I am missing something.
I know I am mixing runtime and compile time. And the trouble is that in each iteration newParams
can be one of any of the types in ParamMap, but it is depending on the currentConfig.key
.
I currently just cast it to any
for it to work:
const newParams = currentGetter(currentConfig.params as any);
Upvotes: 0
Views: 143
Reputation: 2534
You can two issues here. The first thing is that your mapping function (the one you pass to configArr.map
) is non-generic and thus currentConfig
stands for all possible TheEnum
values (and in particular, the value is allowed to be changed), but in reality, currentConfig
stands for one particular TheEnum
value and does not change throughout the mapping function, so what you need is a generic mapping function such as:
configArr.map(<T extends TheEnum>(currentConfig: T) => {
...
}
Secondly, for the moment, TypeScript cannot resolve individual value type based on index signature (expect in the case of tuples), so for getterMap[currentConfig.key]
, the best thing TypeScript can do is to treat it as the union of all possible value types, even as we apply the generic constraint just mentioned. So unfortunately we still need a little bit of any
here:
configArr.map(<T extends TheEnum>(currentConfig: Config<T>) => {
const currentGetter: ParamGetterFn<T> = getterMap[currentConfig.key] as any;
const newParams = currentGetter(currentConfig.params);
...
})
But once we've done this, you have your newParams
correctly identified as ParamMap[T]
and you can continue doing your thing. See this Playground Link.
However, since you still don't know what T
is in this particular context, there's not much manipulations you can actually perform on newParams
, except for those properties that are shared by all ParamMap
subtypes. You either have to convert you object back to any
(which will make all efforts above meaningless), or use complicated type-guard functions to figure out the exact subtype of newParams
in order to write any manipulations for individual subtypes.
One thing TypeScript does not do is discriminating generics (by that, I mean to narrow down the type of generic parameter based on evidences); it only discriminates union types. So in order to utilize this, you have to put things in the form of union types and forget about generics. Of course, your generic definition of ParamGetterFn
and GetterMap
helps you to write your getterMap
instance, so let's keep it that way, but as for the mapping lambda, we need to give up generics:
// construct a type that is the union of all possible generic subsitution
type ConfigUnion = {
[Key in TheEnum]: Config<Key>;
}[TheEnum];
function caller(configArr: Config<TheEnum>[]) {
configArr.map((currentConfig: Config<TheEnum>) => {
const currentGetter = getterMap[currentConfig.key] as any;
const newParams = currentGetter(currentConfig.params) as ConfigUnion;
// now TypeScript can discriminate the actual type based on its key.
if (newParams.key == TheEnum.Foo) {
newParams.params.a // OK
newParams.params.z // not OK
}
if (newParams.key == TheEnum.Bar) {
newParams.params.z // OK
}
})
}
Upvotes: 1