typescriptgenericstypescript-genericsunion-types

Correlated Union Types from Optional object in TypeScript


The following example snippet in TypeScript maps values of an array arr to types contained in Mappings, using a mapper object which contains transformer functions for each arr item: (Playground)

const arr = ["foo", "12", 42] as const;

type Mappings = { foo: boolean, "12": number, 42: string };

type MapperArgs<K extends (typeof arr)[number]> = {
    v: K,
    i: number
};
const mapper: {
    [K in (typeof arr)[number]]: (o: MapperArgs<K>) => Mappings[K]
} = {
    foo: ({ v, i }) => v.length + i > 4,
    "12": ({ v, i }) => Number(v) + i,
    42: ({ v, i }) => `${v}${i}`,
}

To run mapper over all items, the usual arr.map won't work as expected:

arr.map((v) => mapper[v]({ v })); /* TS error 2345: ... intersection was reduced to 'never' because ... has conflicting types in some constituents */

Apparently, this is because of TypeScripts limitations regarding correlated union types. It is possible to use a generic function to work around the issue:

const resolveMapper = <K extends keyof typeof mapper>(key: K, o: MapperArgs<K>) => mapper[key](o);
arr.map((v, i) => resolveMapper(v, { v, i })); // ok ✅

However, this won't work after making some mappings optional:

type SetOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// ...
const mapper: SetOptional<{
    [K in (typeof arr)[number]]: (o: MapperArgs<K>) => Mappings[K]
}, "foo"> = /* ... */

const resolveMapper = <K extends keyof typeof mapper>(key: K, o: MapperArgs<K>) => mapper[key]?.(o); /* not ok ❌ */

Is there a way to solve this problem without suppressing the error or using type assertions/any?


Solution

  • The approach to dealing with correlated unions (see ms/TS#30581) as described in microsoft/TypeScript#47109 only works when the operations and types are in a particular form. You have to have some "base" object type, and then write things as generic indexes into that base type, or into mapped types over the properties of that base type.

    Your Mappings is a reasonable base object type, and { [K in (typeof arr)[number]]: (o: MapperArgs<K>) => Mappings[K] } is a mapped type over keyof Mappings (I'm not sure why you're using (typeof arr)[number] instead of keyof Mappings, but I'm not going to delve into that... I'll just use the equivalent type { [K in keyof Mappings]: (o: MapperArgs<K>) => Mappings[K] }.

    But when you wrap it in SetOptional<⋯, "foo"> it is no longer a mapped type. It's an intersection of mapped types, each piece of which deals differently with a different set of properties. The only reason { [K in keyof Mappings]: (o: MapperArgs<K>) => Mappings[K] } works is because the compiler can "see" that for generic K, indexing with K gives the single generic function type (o: MapperArgs<K>) => Mappings[K]. Try that with SetOptional<⋯, "foo"> and you'll get some weird intersection thing that the compiler can't evaluate without knowing what K is.


    The closest you can get to making this work is to apply SetOptional to Mappings to get a new base type, and then map over that instead. It could look like this:

    type PartMappings = SetOptional<Mappings, "foo">;
    
    const mapper: {
        [K in keyof PartMappings]: (o: MapperArgs<K>) => PartMappings[K]
    } = {
        foo: ({ v, i }) => v.length + i > 4,
        "12": ({ v, i }) => Number(v) + i,
        42: ({ v, i }) => `${v}${i}`,
    }
    
    const resolveMapper = <K extends keyof typeof mapper>(
        key: K, o: MapperArgs<K>) => mapper[key]?.(o)
    //  const resolveMapper: <K extends "foo" | "12" | 42>(
    //  key: K, o: MapperArgs<K>) => PartMappings[K]
    

    Now the compiler sees that the return type of resolveMapper is PartMappings[K], which is accurate.


    Notice that I said it's the closest you can get. It's not fully type safe. For watever reason, (typeof Mapper)[K] is not seen as possibly nullish, so you can write the incorrect code mapper[key](o) in your function instead of mapper[key]?.(o) and not get an error. This is unfortunate, but it seems to be part of how ms/TS#47109 was implemented. One might consider filing a bug report about it, but even if it is determined to be a bug, the approach in ms/TS#47109 is not a panacea. TypeScript isn't fully type safe in general, and neither is ms/TS#47109 in particular; see microsoft/TypeScript#48730.

    So when considering whether or not to refactor to this code, the bar is, for better or worse, not "does this refactoring make the type checking fully accurate", but just "does it make it more accurate than it would be with alternatives like type assertions" (and of course where you weigh risks of false positives to false negatives according to your use cases.)

    Playground link to code