Lesson 23 · Module 3.6 — Type-level programming

Record → discriminated union: { [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.

The one rule

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

Half one: tag each payload with its key

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.

Half two: [keyof T] flattens object into union

Now 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).

What a discriminated union is, and why it’s worth building

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

The whole idiom, read in one pass

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.

The flatten step is the easy thing to miss

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.

Check yourself

Recall, don’t re-read. (Answers reveal on click.)

In { [K in keyof T]: … }[keyof T], what does the trailing [keyof T] do?
In { type: K } & T[K], what is the role of { type: K }?
Drop the trailing [keyof T] from the idiom. What are you left with?

Primary source

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.

Try it yourself

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.