You model your states with discriminated unions and lock in their exhaustiveness with the never type.
Open this lesson in Kodokonnever is the empty type: no value inhabits it. It appears when the compiler proves that a code path is impossible - after exhausting every member of a union, for example. That property makes it a design tool: if you can get the compiler to say never in the default branch of a switch, you have static proof that every case is handled. This is the foundation of exhaustiveness checking.
You discovered discriminated unions in the previous module; now it is time to turn them into a genuine design tool. Compared to a single interface stuffed with optional fields, a discriminated union makes impossible states unrepresentable: you cannot have both data and error, or a success without data. It is the go-to pattern for loading states, operation results, and event messages.
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function render(state: FetchState<string[]>): string {
switch (state.status) {
case "idle": return "Waiting";
case "loading": return "Loading…";
case "success": return state.data.join(", ");
case "error": return state.error.message;
}
}You have already come across the assertNever guard called in the default branch; let's now dissect exactly how it works. As long as every case is covered, its parameter has type never there and everything compiles. Add a variant to the union without updating the switch, and the forgotten variant is no longer assignable to never: compile-time error, with the name of the missing case in the message. You turn a production bug into a refused build.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; width: number; height: number };
function assertNever(value: never): never {
throw new Error("Unhandled case: " + String(value));
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
case "rect": return shape.width * shape.height;
default: return assertNever(shape);
}
}Two advanced subtleties deserve your attention. First, discriminant narrowing propagates to nested structures only if you test the enclosing object directly; destructuring the discriminant on its own breaks the link (except for the cases handled since TypeScript 4.6 for joint destructuring). Second, never is absorbed in unions (string | never is just string): this is what allows conditional types to filter out variants, as Exclude does.
type Event =
| { type: "click"; x: number; y: number }
| { type: "keypress"; key: string };
type EventOf<T extends Event["type"]> =
Extract<Event, { type: T }>;
type ClickEvent = EventOf<"click">;
function handle<T extends Event["type"]>(
type: T,
handler: (event: EventOf<T>) => void
): void {
/* register the handler */
}
handle("click", (e) => console.log(e.x, e.y));Shape without modifying the switch that contains assertNever?string | never?neverstringunknownstring | undefined