Lesson 23 · Module 3.6 — Type-level programming
{ [K in keyof T]: … }[keyof T]One idiom shows up everywhere in library types, and it bundles this whole module into a
single line: { [K in keyof T]: { type: K } & T[K] }[keyof T]. It turns a record of
payloads into a discriminated union. Read it in two halves — the mapped type, then the
trailing [keyof T] that flattens it — and it stops being scary.
A mapped type makes an object; indexing it by [keyof T] turns that object into
the union of its value types. So { …mapped… }[keyof T] means “build a new
object, then collapse it into a union of everything inside.”
Start from a record where each key is an event name and each value is that event's payload. The mapped
type rebuilds it, intersecting a { type: K } discriminator onto each payload:
type EventPayloads = {
click: { x: number; y: number };
keydown: { key: string };
};
type Step1 = { [K in keyof EventPayloads]: { type: K } & EventPayloads[K] };
// resolves to an OBJECT, still keyed by name:
// {
// click: { type: "click"; x: number; y: number };
// keydown: { type: "keydown"; key: string };
// }
For each key K, { type: K } stamps the key name as a literal discriminator and
& EventPayloads[K] merges in the original payload. Note we are still holding an
object here — the keys haven't gone anywhere yet.
[keyof T] flattens object into unionNow apply the trick from Lesson 16: indexing an object type by the union of all its keys
yields the union of all its value types. That is the entire job of the trailing
[keyof T]:
type Events = Step1[keyof EventPayloads];
// keyof EventPayloads = "click" | "keydown"
// indexing Step1 by that union collects both values into a union:
// | { type: "click"; x: number; y: number }
// | { type: "keydown"; key: string }
The object's keys vanish; only its values survive, joined with |. Because every value
carries a literal type field, the result is a discriminated union — TypeScript can
narrow it with a switch (e.type).
A discriminated union is a union of object types that all share one common field whose
literal type differs in each member. That shared field — here type — is the
discriminant (or “tag”). Because each member’s tag is a unique literal ("click" vs
"keydown"), TypeScript can read the tag and know exactly which member you are holding:
type Events =
| { type: "click"; x: number; y: number }
| { type: "keydown"; key: string };
function handle(e: Events) {
switch (e.type) {
case "click":
return e.x + e.y; // here TS knows e is the click member
case "keydown":
return e.key; // here e is the keydown member
}
// reaching for e.key in the "click" branch would be a compile error
}
That automatic narrowing is the whole payoff. Testing e.type in an
if or switch tells TypeScript which shape you have, so inside each branch you can
safely reach the fields only that member owns — and you get an error the instant you touch a field that does
not belong to it. A plain union with no shared tag, like { x: number } | { key: string },
cannot be narrowed this cleanly: there is no single field to switch on. The { type: K } stamp
from Half one is exactly what buys you this — which is the whole reason the idiom bothers to add it. (A
second payoff: once you handle every tag, TypeScript can flag the day someone adds a new event — the
exhaustiveness check from Lesson 1.6.)
type ToUnion<T extends Record<string, any>> = {
[K in keyof T]: { type: K } & T[K]; // 1. tag each payload → object
}[keyof T]; // 2. index by all keys → union of values
Read right-to-left at the end: build the mapped object, then immediately [keyof T] it down
into a union. The mapped type, indexed access, and the value-union behavior from this module all meet in
those two lines. Spot the trailing }[keyof T] and you can name the pattern instantly:
record in, discriminated union out.
Without the trailing [keyof T] you are left with an object keyed by name, not a
union. The mapped braces and the index are a package deal: { … } alone keeps the keys;
{ … }[keyof T] throws the keys away and keeps the values.
Recall, don’t re-read. (Answers reveal on click.)
{ [K in keyof T]: … }[keyof T], what does the trailing [keyof T] do?{ type: K } & T[K], what is the role of { type: K }?[keyof T] from the idiom. What are you left with?The two handbook pages behind this idiom are
Mapped Types and
Indexed Access Types
(the [keyof T] flatten). For the payoff, see
Discriminated Unions.
Best way to feel it: paste ToUnion into the
TS Playground and delete the [keyof T] to
watch the union snap back into an object.
Spot a }[keyof T] in real code and want to confirm it's this pattern? Read it as a
record-to-union flatten and check for the trailing index. That closes Module 3 — you can now read mapped
types, key remapping, conditionals with infer, distribution, template-literal parsing, and
this flatten idiom on sight.