Derived Event Unions

The Course
•
6 min read

An event map names each payload once: added has title and removed has id. We can derive a discriminated union by mapping each key K to one record, then indexing that mapped record with keyof E to collect its values.

Read EventOf<E> in two passes. The inner mapped type builds an object whose added property is an added event record and whose removed property is a removed event record. The final [keyof E] selects all those records as a union. This is the same indexed-access tool you used in intermediate, applied to a derived object.

A single record with type:keyof E and payload:E[keyof E] loses correlation: it allows any name beside any payload. The union below preserves each valid pair. Narrowing on event.type then exposes the right payload without a cast.

This type still does not parse external messages. A decoder must construct one valid branch at runtime. The capstone keeps that decoder explicit, so adding an event requires both a new payload contract and a deliberate parsing branch.

Predict, check, and run

Write the output before running. Use Check types first, then Check & run. Types disappear at runtime: explain both what the compiler checks and what the code actually does. Each editor is an independent ES module; await asynchronous work explicitly.

Predict, then runTypeScript
type Events = {
  added: { title: string };

  removed: { id: string };
};

type ByName = {
  [K in keyof Events]: {
    type: K;

    payload: Events[K];
  };
};

type AppEvent = ByName[keyof Events];

function label(event: AppEvent): string {
  return event.type === "added" ? event.payload.title : event.payload.id;
}

console.log(label({ type: "added", payload: { title: "Types" } }));
console.log(label({ type: "removed", payload: { id: "b1" } }));

Enable JavaScript to edit and type-check this example.

Check your prediction
Types
b1

Combine the two steps

In the prediction, ByName["added"] selects one added event record. ByName["added" | "removed"] selects either record; keyof Events supplies that key union. Replacing the concrete Events name with a type parameter gives the reusable form used below and in the project:

type EventOf<E> = {
  [K in keyof E]: {
    type: K;

    payload: E[K];
  };
}[keyof E];

Our event maps have required named properties. Start with their two concrete branches before trying to generalize the helper to optional maps or unrelated schemas.

Read the compiler error

Predict the rejected relationship before checking. Read the real diagnostic and its source location; fix the contract or the value instead of hiding it with an assertion.

Find and fix the type errorTypeScript
type Events = {
  added: { title: string };

  removed: { id: string };
};

type EventOf<E> = {
  [K in keyof E]: {
    type: K;

    payload: E[K];
  };
}[keyof E];

const event: EventOf<Events> = { type: "added", payload: { id: "b1" } };

console.log(event);

Enable JavaScript to edit and type-check this example.

Check your diagnosis

The added branch requires title. It cannot borrow the removed branch’s payload just because id is valid somewhere in the union.

Keep name and payload in the same alternative

A union of event names plus an independent union of payloads describes too many pairs. The derived event union maps each name to one record first, then collects those records. This preserves the relationship that narrowing relies on.

Use a concrete mismatched pair to test the design. A type definition that accepts every valid example can still be too broad; only a negative example reveals that it also permits a removed payload under an added name.

Keep name and payload in the same alternative: traceTypeScript
type Events = {
  added: { title: string };

  removed: { id: string };
};

type Loose = {
  type: keyof Events;

  payload: Events[keyof Events];
};

const mismatch: Loose = { type: "added", payload: { id: "b1" } };

console.log(mismatch.type, "title" in mismatch.payload);

Enable JavaScript to edit and type-check this example.

Check your prediction
added false

This compiles because the two unions are independent. It is a deliberate counterexample to the desired contract, not validation evidence.

Keep name and payload in the same alternative: contrastTypeScript
type Events = {
  added: { title: string };

  removed: { id: string };
};

type EventOf<E> = {
  [K in keyof E]: {
    type: K;

    payload: E[K];
  };
}[keyof E];

function label(event: EventOf<Events>): string {
  if (event.type === "added") {
    return event.payload.title;
  }

  return event.payload.id;
}

console.log(label({ type: "removed", payload: { id: "b1" } }));

Enable JavaScript to edit and type-check this example.

Check your prediction
b1

The branch narrows the full paired record. Passing mismatch here must fail because it cannot establish a valid correlated alternative.

Try the rule on new inputs

Write the expanded two-member union by hand, then compare it with EventOf. Test a mismatched payload and a missing field. Explain why neither shape parses unknown wire data by itself.

Build it yourself

Exercise

Derived Event Unions: practise

Derive a union from {started:{job:string};finished:{count:number}}. Write a formatter narrowing by type, then print Build and 0. Confirm a finished event carrying only job fails checking.

Your workspaceTypeScript
// Start from the stated contract. Add a valid call and an edge case.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

Build one event record per key, then take the mapped object’s union of values.

Hint 2: a first step

Index the completed mapped type with keyof E after the closing brace.

Show one solution
One possible solutionTypeScript
type Events = {
  started: { job: string };

  finished: { count: number };
};

type EventOf<E> = {
  [K in keyof E]: {
    type: K;

    payload: E[K];
  };
}[keyof E];

function label(event: EventOf<Events>): string {
  if (event.type === "started") {
    return event.payload.job;
  }

  return String(event.payload.count);
}

console.log(label({ type: "started", payload: { job: "Build" } }));
console.log(label({ type: "finished", payload: { count: 0 } }));

Enable JavaScript to edit and type-check this example.

Build
0

Trace the implementation against the task before comparing styles. A passing example supports that case; use the changes below to challenge the wider contract.

Change it and test it

Contrast the derived union with {type:keyof Events;payload:Events[keyof Events]}. Construct a mismatched pair and observe which model rejects it. Explain the relationship that the broad record forgot.

Was this article helpful?

Your response is saved on this device.