typescriptmappingkeyof

Typescript: variable of type "keyof T" used to index T is resolved as union of T values


interface SubProps1<T> {
    foo: boolean
    bar: string
    baz: T
}

interface SubProps2<T> {
    fooz: boolean
    barr: string
    bazd: Array<T>
}

interface Props<T> {
    sub1: SubProps1<T>
    sub2: SubProps2<T>
}

type CSSObjectWithLabel = Record<string, unknown>

type StylesConfig<T> = {
    [K in keyof Props<T>]?: StylesConfigFunction<Props<T>[K]>;
}

type StylesConfigFunction<SubProps> =
    (base: CSSObjectWithLabel, props: SubProps) => CSSObjectWithLabel;

function mergeStyles<T>(
    nonColorStyles: StylesConfig<T>,
    colorStyles: StylesConfig<T>,
    ): StylesConfig<T> {

type S = StylesConfig<T>

const result: S = {};

const uniqueKeys = new Set([
    ...Object.keys(nonColorStyles),
    ...Object.keys(colorStyles)
] as (keyof S)[])

function assigner<K extends keyof S>(key: K) {
    result[key] = (base: CSSObjectWithLabel, props: Props<T>[K]): CSSObjectWithLabel => ({
        ...base,
        ...colorStyles[key]?.(base, props),
        ...nonColorStyles[key]?.(base, props)
    })
}

return result;

}

We try to merge into result object different functions with signature in accordance to the key value Type map defined by Props interface.

We face an issue where Props<T>[K] (inside assigner function, on the line we assign result[key] is resolved as the following union SubProps1 | SubProps2. Please refer to joined screenshot.

I understand it's not possible for TS to know the value of key in the assigner function, but its possible to say that the type of result[key] for a given specific key correspond to the specific mapped StylesConfigFunction, hence allowing only the correct SubProps type and not the union of both types.

This looks like a TS limitation, but maybe this limitation is motivated by a reason I'm not aware of.

Is there any reason for this limitation? is it done on purposes? If not, is it possible to make TS smart enough for handling this case? Thanks a lot


Solution

  • When you try to assign to result[key], the compiler complains because it doesn't see the correlation between the types StylesConfig<T>[K] and (base: CSSObjectWithLabel, props: Props<T>[K])=>CSSObjectWithLabel.

    Since microsoft/TypeScript#30769 was introduced with TypeScript 3.5, assignments to index access types have been checked strictly in a way that results in the error you're getting. Right now what's happening is roughly that since StylesConfig<T>[K] is not seen as identical to (base: CSSObjectWithLabel, props: Props<T>[K])=>CSSObjectWithLabel, the compiler widens K to its constraint which is the union type "sub1" | "sub2". Because it doesn't know which one of those key is, it decides to be conservative and only allow assignments if the assigned value would satisfy both possibilities at the same time. That turns the union into an intersection. If you had a value of type StylesConfig<T>["sub1"] & StylesConfig<T>["sub2"], then you could safely assign it to result[key] no mater what key was. And since you're not doing such an assignment, the compiler warns you that it can't be sure what you're doing is safe. After all, if K is widened to "sub1" | "sub2", then the value you're assigning is of type StylesConfig<T>["sub1"] | StylesConfig<T>["sub2"], which is the union and not the intersection. Oh well.


    This is fundamentally the same problem as described in microsoft/TypeScript#30581; when you're trying to compare two union or union-constrained types which are correlated because they both depend on a single value (like key), the compiler doesn't do a full counterfactual analysis on each actual possibility for that value. Instead it loses track of the correlation and complains.

    As such, the recommended fix is described at microsoft/TypeScript#47109, which is to make sure that the two types you compare are seen as identical to each other. The method is to turn StylesConfig<T> into a object type that takes another type argument corresponding to the particular set of keys you're looking at (you can make it default to the full set):

    type StylesConfig<T, K extends keyof Props<T> = keyof Props<T>> = {
      [P in K]?: (
        base: CSSObjectWithLabel,
        props: Props<T>[P]
      ) => CSSObjectWithLabel;
    }
    

    And then make sure that both sides of the assignment are seen as type StylesConfig<T, K>[K]:

    function assigner<K extends keyof S>(key: K) {
      const r: StylesConfig<T, K> = result;
      r[key] = (base: CSSObjectWithLabel, props: Props<T>[K]): CSSObjectWithLabel => ({
        ...base,
        ...colorStyles[key]?.(base, props),
        ...nonColorStyles[key]?.(base, props)
      })
    }
    

    I've widened result (of type StylesConfig<T>) to r (of type StylesConfig<T, K>) which works because it's just looking at the K property. And then r[key] is seen to be of type StylesConfig<T>[K]. Then, because you are just indexing into a mapped type with a generic key, the compiler is able to see that type as ( base: CSSObjectWithLabel, props: Props<T>[K]) => CSSObjectWithLabel;, which is identical to the type of the value you assign.

    And now it compiles with no error.


    This sort of refactoring feels more like an art than a science to me, even though I'm fairly familiar with the method described in microsoft/TypeScript#47109. After all, StylesConfig<T>[K] and StylesConfig<T, K>[K] are the same types already; why aren't they "identical" according to the compiler? I don't have a great answer for that and maybe someone could file a feature request so that this sort of change wouldn't be necessary. For now, though, this is how I would proceed.

    Playground link to code