Derive a WebSocket message union and its handler map from one registry
A WebSocket protocol sends objects discriminated by a type field. The union and the handler map are currently hand-written and drift apart whenever a message is added.
Requirements: derive both from the Messages registry below, so adding a message there is the only edit. Message must be the discriminated union of { type: K } & Messages[K], Handlers must map each type to a handler taking exactly that message, and dispatch must route a message to its handler with each handler seeing its own narrowed payload. No any, no as.
type Messages = {
ping: { at: number };
chat: { room: string; text: string };
};
type Message = /* your code here */;
type Handlers = /* your code here */;
function dispatch(msg: Message, handlers: Handlers): void {
// your code here
}
Write the implementation.
From the registry, a mapped type indexed by keyof yields the union { type: K } & Messages[K], and a second over the same keys the handler map, each taking its own message. dispatch switches on the discriminant; a new message fails to compile.
- ✗Intersecting
keyofwith the union of payloads, which pairs every name with every shape - ✗Hand-writing the union beside the registry so the two drift apart
- ✗Assuming a mapped type cannot produce a union of object types
- →How does
dispatchbecome a compile error the moment a new message is added to the registry? - →How would you extend this so the server's and the client's message sets differ?
The solution
type Messages = {
ping: { at: number };
chat: { room: string; text: string };
};
// The union is derived from the registry: one variant per key
type Message = { [K in keyof Messages]: { type: K } & Messages[K] }[keyof Messages];
// = ({ type: "ping" } & { at: number }) | ({ type: "chat" } & { room: string; text: string })
type Handlers = {
[K in keyof Messages]: (msg: Extract<Message, { type: K }>) => void;
};
function dispatch(msg: Message, handlers: Handlers): void {
switch (msg.type) {
case "ping":
handlers.ping(msg); // msg narrowed to { type: "ping"; at: number }
return;
case "chat":
handlers.chat(msg); // msg narrowed to { type: "chat"; room: string; text: string }
return;
default: {
const never: never = msg; // a new message in the registry ⇒ compile error right here
throw new Error(`unhandled ${JSON.stringify(never)}`);
}
}
}
Why it is written this way
{ [K in keyof Messages]: ... }[keyof Messages]is a mapped type indexed by its own keys. Indexing unions the property values, so the result is a union of variants rather than one object.- The intersection
{ type: keyof Messages } & Messages[keyof Messages]looks shorter but pairs every name with every payload —{ type: "ping"; room: string }would pass too. The discriminant stops discriminating. Extract<Message, { type: K }>pulls out exactly the variant whosetypeisK, sohandlers.chatwill not accept aping.- The
defaultbranch assigning toneveris the exhaustiveness check: addclosetoMessagesand this line becomes a type error immediately.