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.
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.
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.
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.
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
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.
// 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
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
0Trace 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.
Completion is saved in this browser only. It does not measure mastery.