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.
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.- Union means one of several alternatives.
- Intersection combines all requirements.
- Narrow unions before using branch-specific properties.
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.
function describe(value: string | number): string {
if (typeof value === "number") {
return value.toFixed(1);
}
return value.toUpperCase();
}value is a number; after the return, the remaining path is a string. TypeScript follows the control flow to determine the safe type.- Runtime checks can narrow unions.
- Early returns often simplify the remaining type.
- Assertions do not perform runtime validation.
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.
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);
}
}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.- Use literal discriminants for stateful unions.
- Exhaustiveness checks protect code as unions evolve.
- Prefer explicit state variants over loosely related optional properties.
What is the purpose of `assertNever` in an exhaustive switch?