Discriminated Unions & Exhaustiveness Checking

10 questions found

What is a discriminated union, and what role does the common 'discriminant' property play?

Beginner
A discriminated union is a union of object types that all share a common literal-typed property (the discriminant, often called 'kind' or 'type'). Checking that property's value in an if or switch lets TypeScript automatically narrow the whole object to the matching specific type.
type Circle = { kind: 'circle'; radius: number };
type Square = { kind: 'square'; side: number };
type Shape = Circle | Square;
Real-world example Modeling different states of an async request (loading, success, error) as a discriminated union.

Common follow-ups: What happens if two variants accidentally share the same discriminant value?

Union & Intersection Types

How do you narrow a discriminated union inside a switch statement?

Beginner
Switch on the discriminant property; inside each case block, TypeScript automatically narrows the variable to the specific variant matching that case's literal value, giving you safe access to that variant's unique properties.
function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2; // shape is Circle here
    case 'square': return shape.side ** 2;             // shape is Square here
  }
}
Real-world example Calculating an area, rendering a UI component, or serializing a response differently based on a variant's specific shape.

Common follow-ups: Does the same narrowing work with an if/else chain instead of switch?

Type Narrowing & Guards

What is exhaustiveness checking, and how does the 'never' type enable it in a default case?

Intermediate
Exhaustiveness checking uses the fact that after handling every known variant, the remaining type should be 'never' (impossible) — assigning the narrowed value to a variable typed as never in a default/else branch causes a compile error if a new variant is ever added and forgotten, catching incomplete handling at compile time.
function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.side ** 2;
    default: return assertNever(shape); // error here if a case is missing
  }
}
Real-world example Guaranteeing that adding a new Shape variant later forces every switch statement handling shapes to be updated, caught by the compiler.

Common follow-ups: What error message would you see if you added a Triangle variant but forgot to handle it?

Never Unknown & Void Types

How would you model API response states (loading, success, error) as a discriminated union?

Intermediate
Define a union where each state variant has the same discriminant key (e.g. 'status') with a different literal value, plus only the data relevant to that specific state — this prevents accessing 'data' before checking status is 'success', or 'error' before checking status is 'error'.
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function render(state: RequestState<User>) {
  if (state.status === 'success') console.log(state.data); // safe access
}
Real-world example Modeling a data-fetching hook's state so consuming components can't accidentally access 'data' while still loading.

Common follow-ups: How does this compare to representing the same state with several separate optional boolean flags?

Union & Intersection Types

Can you use a discriminant that isn't a string literal, like a number or boolean literal type?

Intermediate
Yes — the discriminant just needs to be a literal type (string, number, or boolean literal) that's unique across the union's variants; TypeScript narrows equally well whether you check with ===, a switch, or even truthy/falsy checks on a boolean discriminant.
type Success = { ok: true; data: string };
type Failure = { ok: false; error: string };
type Result = Success | Failure;

function handle(result: Result) {
  if (result.ok) console.log(result.data);   // Success
  else console.log(result.error);            // Failure
}
Real-world example Using a simple boolean 'ok' discriminant for a lightweight Result<T> type, common in functional-style error handling.

Common follow-ups: Would using a plain 'boolean' (not the literal 'true'/'false') type work the same way as a discriminant?

Types & Interfaces

How do you write a generic Result<T, E> discriminated union type for functional-style error handling, and why is it preferred over throwing exceptions in some codebases?

Advanced
Define a generic union of Ok<T> and Err<E> variants with a common discriminant; unlike throw, which is invisible in a function's type signature, a Result<T, E> return type makes the possibility of failure explicit and forces callers to handle it via the type system rather than relying on documentation or try/catch discipline.
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function parseNumber(input: string): Result<number, string> {
  const n = Number(input);
  return isNaN(n) ? { ok: false, error: 'Invalid number' } : { ok: true, value: n };
}
Real-world example Making error handling explicit and type-checked in a validation or parsing layer, rather than relying on undocumented thrown exceptions.

Common follow-ups: What are the trade-offs of Result<T,E> versus exceptions for deeply nested call chains?

Generics

How does exhaustiveness checking behave differently if the union member types overlap structurally instead of being cleanly discriminated?

Advanced
If variants don't share a unique literal discriminant (or their shapes overlap ambiguously), narrowing based on property checks becomes unreliable — TypeScript may not be able to distinguish which variant you're in, weakening or breaking exhaustiveness checking entirely, since the 'never' trick relies on the compiler correctly eliminating all known variants.
// Poorly discriminated - both have 'value: number', ambiguous
type A = { type: 'a'; value: number };
type B = { value: number }; // no matching 'type' discriminant at all
type Bad = A | B; // narrowing on 'type' alone won't safely narrow B
Real-world example Debugging why a switch/case exhaustiveness check silently fails to catch a missing case, tracing it to a poorly-discriminated union.

Common follow-ups: What's the fix when you discover a union isn't properly discriminated like this?

Union & Intersection Types

How would you write a reducer function (Redux-style) that exhaustively handles every action type in a discriminated union of actions?

Advanced
Type the reducer's action parameter as a discriminated union of all possible Action types (each with a unique 'type' discriminant), switch on action.type, and add a default case using the assertNever(action) pattern — this guarantees the compiler flags any new action type added later that isn't yet handled in the reducer.
type Action =
  | { type: 'increment'; amount: number }
  | { type: 'decrement'; amount: number }
  | { type: 'reset' };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'increment': return state + action.amount;
    case 'decrement': return state - action.amount;
    case 'reset': return 0;
    default: return assertNever(action);
  }
}
Real-world example Building a type-safe Redux or useReducer reducer where forgetting to handle a new action type is caught at compile time, not at runtime.

Common follow-ups: How does this pattern scale when action creators are also generated from the same discriminated union?

Never Unknown & Void Types

How do you nest discriminated unions to model a state machine with multiple independent dimensions of state?

Advanced
Combine multiple discriminants (or nest one discriminated union inside a variant of another) when a single state genuinely has more than one independent axis of variation — though often it's cleaner to flatten everything into a single discriminant enumerating every valid COMBINATION of states, avoiding impossible in-between states entirely.
type FetchState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string; cached: boolean }
  | { status: 'error'; error: string; retryable: boolean };
// 'cached' and 'retryable' only exist on their relevant variant, preventing impossible combinations
Real-world example Modeling a network request state machine where 'cached' only makes sense once data has successfully loaded.

Common follow-ups: What is the 'make impossible states impossible' design principle, and how do discriminated unions embody it?

Union & Intersection Types

How does TypeScript narrow a discriminated union when the discriminant check happens inside a called function rather than inline?

Advanced
By default, TypeScript does NOT narrow based on an external function's return value unless that function is declared as a user-defined type predicate (using the 'x is Type' return type syntax) — a plain boolean-returning helper won't narrow the union at the call site, even if its logic is equivalent to an inline check.
function isCircle(shape: Shape): shape is Circle {
  return shape.kind === 'circle'; // type predicate enables narrowing
}
function badIsCircle(shape: Shape): boolean {
  return shape.kind === 'circle'; // plain boolean does NOT narrow
}
if (isCircle(shape)) { shape.radius; } // works
// if (badIsCircle(shape)) { shape.radius; } // Error: radius doesn't exist on Shape
Real-world example Extracting a reusable, well-typed 'isX' check into a shared utility function used across multiple switch/if statements.

Common follow-ups: What is the exact syntax for declaring a custom type predicate function?

Type Narrowing & Guards