3 Topics
Chapter•75 Minutes

Union Types, Narrowing, and Type Guards

Represent alternatives safely and use control flow, discriminants, predicates, and exhaustive checks.

Union and Intersection Types

A union A | B means a value can be either alternative; code can safely access only members common to all alternatives until it narrows the value. An intersection A & B combines requirements, so a value must satisfy both types. Intersections are useful for composing object capabilities, but conflicting property definitions can produce impossible types.

Choose unions to model mutually exclusive states and intersections to combine compatible contracts. Avoid treating A | B as though it automatically exposes all properties from both types; that would be unsafe because a value might only be one branch.

Union and intersection composition
type HasId = { id: string };
type HasTimestamp = { createdAt: Date };
type StoredRecord = HasId & HasTimestamp;

type Result = { data: string } | { error: string };
💡 StoredRecord requires both id and createdAt. Result is one of two shapes; consumers must check which branch they received before accessing branch-specific properties.
Key Takeaways
  • Union means one of several alternatives.
  • Intersection combines all requirements.
  • Narrow unions before using branch-specific properties.
Knowledge Check

What does `A & B` mean for a value?

Control-Flow Narrowing

TypeScript tracks checks such as typeof, equality comparisons, in, instanceof, and early returns to refine a variable's type along a control-flow path. This is called narrowing. It is usually better to narrow using a real runtime fact than to force a type assertion.

For object unions, checking a property or a discriminant can identify the current variant. Be careful with optional properties and overlapping object shapes: a check is only as strong as the runtime fact it establishes.

Narrow an unknown input
function describe(value: string | number): string {
  if (typeof value === "number") {
    return value.toFixed(1);
  }
  return value.toUpperCase();
}
💡 Inside the first branch value is a number; after the return, the remaining path is a string. TypeScript follows the control flow to determine the safe type.
Key Takeaways
  • Runtime checks can narrow unions.
  • Early returns often simplify the remaining type.
  • Assertions do not perform runtime validation.
Knowledge Check

After `if (typeof value === "number")`, what is the type of `value` inside that branch for `string | number`?

Discriminated Unions and Exhaustiveness

A discriminated union gives each variant a shared property whose value is a distinct literal, often called a tag or discriminant. This is a robust way to model network states, commands, UI states, and domain events. A switch on the tag narrows each case to its corresponding object.

For exhaustive handling, use a helper that accepts never in the default branch. If a new variant is added later, the compiler can flag code that forgot to handle it. Exhaustiveness works best when the union is explicit and the discriminant values are unique.

Exhaustive state handling
type LoadState =
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function assertNever(value: never): never {
  throw new Error(`Unexpected state: ${JSON.stringify(value)}`);
}

function render(state: LoadState): string {
  switch (state.status) {
    case "loading": return "Loading…";
    case "success": return state.data.join(", ");
    case "error": return state.message;
    default: return assertNever(state);
  }
}
💡 Each case narrows state to one variant. If a new LoadState member is added without a case, the default value is no longer never, causing a type error.
Key Takeaways
  • Use literal discriminants for stateful unions.
  • Exhaustiveness checks protect code as unions evolve.
  • Prefer explicit state variants over loosely related optional properties.
Knowledge Check

What is the purpose of `assertNever` in an exhaustive switch?