Search code examples
typescripttypescript-genericstypescript-types

Why does an intersection with a discriminated union in a mapped type give a union of all types intead of narrowing it?


I have a simple discriminated union like this:

enum Kind {
    A = "a",
    B = "b",
    C = "c"
}

type Value =
    | { kind: Kind.A, value: 1 }
    | { kind: Kind.B, value: 2 }
    | { kind: Kind.C, value: 3 }

I want to transform in into an object type where each key in Kind would have a corresponding value from Value["value"]. For the example above it should look like { a: 1, b: 2, c: 3 }.

To select a value from a discriminated union I use an intersection with an object with a concrete kind property:

type t1 = (Value & { kind: Kind.A })["value"]
//   ^? type t1 = 1

But when I do the same thing inside a mapped type, it spits out Value["value"] instead:

type t2 = { [K in Kind]: (Value & { kind: K })["value"] }
//   ^? type t2 = { a: 1 | 2 | 3, b: 1 | 2 | 3, c: 1 | 2 | 3 }

What am I missing here?

Here's a working solution to my problem:

type t3 = { [K in Kind]: (Value & { kind: K }) }

// This almost works, the main problem has magically disappeared by doing this in two iterations instead of one
// But now TS says that "value" cannot be used to index t3[K]
type t4 = { [K in Kind]: t3[K]["value"] }

// Actually works
type t5 = { [K in Kind]: t3[K] extends infer T extends { value: unknown } ? T["value"] : never }

Also solved the indexing issue by merging the intersection first:

type Merge<T> = { [K in keyof T]: T[K] }

type t6 = { [K in Kind]: Merge<Value & { kind: K }> }
type t7 = { [K in Kind]: t6[K]["value"] } 

I've narrowed the issue down to the following:

enum Kind {
    A = "a",
    B = "b",
    C = "c"
}

type Value =
    | { kind: Kind.A, value: 1 }
    | { kind: Kind.B, value: 2 }
    | { kind: Kind.C, value: 3 }


type t1 = Kind.A extends infer K extends Kind.A ? (Value & { kind: K })["value"] : never
//   ^? type t1 = 1 | 2 | 3

type t2 = (Value & { kind: Kind.A })["value"]
//   ^? type t2 = 1

Apparently TS treats Kind.A differently if it's a type parameter (even if it has an equivalent constraint as in the example above).

Why is this?

TS Playground Example


Solution

  • This is a design limitation of TypeScript, as described in microsoft/TypeScript#45428.

    The problem with indexing into an intersection type like (Value & { kind: K })["value"] is that the compiler doesn't realize that it should consult {kind: K} at all when figuring out the value property type, since {kind: K} doesn't have a value property.

    Given a type like (A & B)[P] where P extends keyof A but not keyof B (and where A & B isn't immediately evaluated already) the compiler will take the shortcut of just evaluating it as A[P]. This is almost always the right thing to do, but you've found a situation where it isn't. Or at least where it's not clearly correct.

    What would you say {foo: never, bar: 2}["bar"]} should be? It seems like it should be 2, but what if there is a rule that collapses {foo: never, bar: 2} to never because it cannot exist? Then maybe it should be (never)["bar"] which is never. It's arguable that it could be either 2 or never depending on when the collapsing rule is supposed to apply. Your case is equivalent to this, just with a union of these types: ({foo: "a", bar: 1) | {foo: never, bar: 2})["bar"]) could either be 1 | 2 or just 1 depending on when the collapsing rule applies.

    But even if we decide that it should be never or 1, the compiler doesn't bother to figure this out, because to do so would make evaluating types more expensive all the time, for only the occasional improvement to correctness. So it's a design limitation of TypeScript. The workaround is to use the Extract utility type as described in the GitHub issue and the other answer to the question.