Xander
Xander

Reputation: 181

TypeScript infering the type while looping over a map

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

Answers (1)

Mu-Tsun Tsai
Mu-Tsun Tsai

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.

Update

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
        }
    })
}

Playground Link

Upvotes: 1

Related Questions