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?
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.