Typorithm · easy

4. Discriminated unions and exhaustive decisions

typescriptstable-typescriptinterviewindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

4. Discriminated unions and exhaustive decisions

0%

Concept and contract

Represent valid alternatives and make missing branches visible when a domain evolves.

A union is a set of alternatives, not an object containing every member property at once. Reading a property without narrowing is safe only when that operation makes sense for all remaining alternatives. A shared literal discriminant lets control flow identify the active member.

Model request states as idle, loading, success and failure records instead of independent boolean flags. A success branch can require data; a failure branch can require a message. This removes representable contradictions such as loading with a success-only payload.

Mechanism and worked example

The example switches on kind, which narrows each branch before reading its associated field.

The example switches on kind, which narrows each branch before reading its associated field. The default branch assigns the remaining value to never. When a new variant is introduced, that assignment fails until the renderer handles it.

The never check is a compile-time completeness test, not a runtime parser. A server can still send an unrecognized tag. Validate external data before constructing the trusted union; an unexpected runtime value should follow a deliberate boundary error policy rather than being blindly asserted.

Complete worked example

typescript

type Count = { kind: "ready"; count: number } | { kind: "failed"; message: string };
function describe(value: Count): string {
  switch (value.kind) {
    case "ready": return String(value.count);
    case "failed": return value.message;
    default: { const impossible: never = value; return impossible; }
  }
}
Reasoning
Each branch gets only the payload justified by its kind; never checks completeness.

Counterexamples and common mistakes

A missing success payload is rejected because that member requires count.

A missing success payload is rejected because that member requires count. An object with an optional count on every state would weaken the invariant and spread undefined handling across callers. Keep related information together under the tag that justifies it.

Do not destructure and then mutate the discriminant separately from its payload. Preserve the relationship while branching. Likewise, a string tag from an unvalidated URL is not automatically the closed discriminant union just because its spelling resembles an allowed value.

Rejected counterexample: expected compiler diagnostic

typescript

type Count = { kind: "ready"; count: number } | { kind: "failed"; message: string };
// @ts-expect-error A ready result requires its payload.
const invalid: Count = { kind: "ready" };
Reasoning
The tag alone is insufficient when that variant requires a payload.

Independent practice

Create a download result with ready containing a URL and failed containing an error message.

Create a download result with ready containing a URL and failed containing an error message. Write a description function that handles both exhaustively, then extend the union with queued carrying a position. Update the renderer and explain the diagnostic you received.

Check that queued output includes the numeric position and that failed never accesses a URL. Reject a ready record without its URL at compile time. Avoid converting the whole result to any merely to satisfy the exhausted branch.

Step 1: Implement ready and failed branches. Step 2: Add queued and restore exhaustiveness.

Practice starter: solve before reading the solution

typescript

type Download = { kind: "ready"; url: string } | { kind: "failed"; message: string } | { kind: "queued"; position: number };
function label(value: Download): string { throw new Error("TODO"); }
Reasoning
Add queued without weakening the contracts of ready and failed.
  • Hint 1: Switch on kind rather than optional payload fields.
  • Hint 2: Assign the unhandled remainder to never.

Solution, tests and trade-offs

The solution keeps all three variants explicit and uses a never fallback after the switch.

The solution keeps all three variants explicit and uses a never fallback after the switch. The independent task changes the domain from counting to downloads so the solution requires transferring the pattern rather than copying property names.

Test one value per variant, including position zero. Re-run the compiler after temporarily adding a fourth variant. A real exhaustiveness test should fail when the union expands; a default branch returning a generic label would conceal that regression.

Practice solution and acceptance checks

typescript

type Download = { kind: "ready"; url: string } | { kind: "failed"; message: string } | { kind: "queued"; position: number };
function label(value: Download): string {
  switch(value.kind) {
    case "ready": return value.url;
    case "failed": return value.message;
    case "queued": return `Queue ${value.position}`;
    default: { const missing: never = value; return missing; }
  }
}

// Acceptance checks
if(label({kind:"queued",position:0})!=="Queue 0"||label({kind:"ready",url:"/file"})!=="/file"||label({kind:"failed",message:"no"})!=="no")throw new Error("download");
Reasoning
The fallback becomes unreachable only after every declared variant has been handled.

Interview reasoning and next step

For interviews, distinguish a union of objects from an object of union-valued fields.

For interviews, distinguish a union of objects from an object of union-valued fields. Explain how the discriminant preserves correlation and why exhaustive rendering is useful even when every current branch returns a string.

Continue with narrowing mechanisms beyond tags: typeof, property presence and custom guards. Those are needed to move from uncertain values toward the well-formed union that the application wants to use.

Optional stretch — after the core task works: Add canceled and explain the new diagnostic.

Represent valid alternatives and make missing branches visible when a domain evolves.