A generic signature can look stronger than it is. send<K extends keyof E>(name:K,payload:E[K]) works for individual literal keys, but a caller can supply K as the whole key union. Then payload becomes a union too, and the pair can be mismatched. Separate parameters do not always preserve identity.
Our gateway accepts one derived event union and switches on its discriminant. Each branch calls a known channel with its known payload. This small explicit switch is easy to audit and gets an exhaustiveness check when another event is added.
Another safe design is a separate typed port per event: added.publish accepts only an added payload, and removed.publish accepts only a removed payload. We combine both designs: typed ports for trusted code and a validated union at the unknown-data boundary.
Do not use event.payload as never to bypass a dispatch error. An assertion can suppress a missing relationship without proving the branch receives the correct payload. Keep this week’s event set small enough that explicit branches are clearer than a clever generic runtime registry.
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 };
};
function loose<K extends keyof Events>(name: K, payload: Events[K]): void {
console.log(name, "title" in payload);
}
// This compiles: K is explicitly the union, so the pair is not correlated.
loose<keyof Events>("added", { id: "b1" });Enable JavaScript to edit and type-check this example.
Check your prediction
added false 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 Event =
| {
type: "added";
payload: { title: string };
}
| {
type: "removed";
payload: { id: string };
};
function dispatch(event: Event): string {
return event.payload.title;
}
console.log(dispatch({ type: "removed", payload: { id: "b1" } }));Enable JavaScript to edit and type-check this example.
Check your diagnosis
Narrow event.type before accessing title; the removed branch has no title.
Literal keys and union keys provide different evidence
A helper that accepts a generic key may look safe for literal calls while becoming too permissive when the caller supplies a union key. Inspect the whole public signature, including explicit type arguments, rather than inferring safety from two happy-path calls.
Use a correlated event value or a separately typed port when the name controls the payload. An explicit exhaustive switch is often clearer than dynamic indexing that loses the relationship and then needs an assertion to compile.
type Events = {
added: { title: string };
removed: { id: string };
};
function loose<K extends keyof Events>(name: K, payload: Events[K]): string {
return String(name);
}
console.log(loose<keyof Events>("added", { id: "wrong branch" }));Enable JavaScript to edit and type-check this example.
Check your prediction
added The accepted mismatch exposes the union-key loophole. The function body returning a name is harmless here, but a real dispatcher relying on a title would receive the wrong shape.
type Added = { title: string };
type Removed = { id: string };
const added = {
publish(value: Added) {
return value.title;
}
};
const removed = {
publish(value: Removed) {
return value.id;
}
};
console.log(added.publish({ title: "Types" }));
console.log(removed.publish({ id: "b1" }));Enable JavaScript to edit and type-check this example.
Check your prediction
Types
b1 Each known port accepts one payload contract. A generic runtime name is no longer required at the call site, so the relationship is represented directly by the property selected.
Try the rule on new inputs
Write a negative caller for an added event carrying only id. Compare whether the generic pair, derived union and explicit added port reject it. Explain why an as never assertion would hide evidence instead of supplying it.
Build it yourself
Correlation & Dispatch: practise
Write a correlated event union and dispatch it to two separate formatter functions. Added prints Added Types and removed prints Removed b1. Use a switch and a never helper for exhaustiveness.
// 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
Keep the event object intact while narrowing its type tag.
Hint 2: a first step
Handle each known tag before the default branch calls unreachable(event).
Show one solution
type Event =
| {
type: "added";
payload: { title: string };
}
| {
type: "removed";
payload: { id: string };
};
function unreachable(value: never): never {
throw new Error("Unhandled event");
}
function added(value: { title: string }): string {
return `Added ${value.title}`;
}
function removed(value: { id: string }): string {
return `Removed ${value.id}`;
}
function dispatch(event: Event): string {
switch (event.type) {
case "added":
return added(event.payload);
case "removed":
return removed(event.payload);
default:
return unreachable(event);
}
}
console.log(dispatch({ type: "added", payload: { title: "Types" } }));
console.log(dispatch({ type: "removed", payload: { id: "b1" } }));Enable JavaScript to edit and type-check this example.
Added Types
Removed b1Trace 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
Add a renamed event and confirm the default branch produces a diagnostic until handled. Explain why the loose example above compiles, and why you should test caller contracts as well as function bodies.
Completion is saved in this browser only. It does not measure mastery.