TypeScript Best Practices

Chapter 2 · Practice 2 of 6

Model states as a discriminated union, not a bag of optional fields

From Modeling data with types

Why it matters

A type like { loading: boolean; data?: User; error?: string } allows states that make no sense: loading with an error, or neither data nor error after loading. Every consumer has to guess which combinations are real.

A discriminated union lists each real state with a shared literal field, usually status or kind. The narrowing chapter of the handbook shows how checking that field narrows the whole object, so inside if (state.status === 'success') TypeScript knows state.data exists.

This pattern fits:

  • request state (idle, loading, success, error),
  • events and messages,
  • payment or order lifecycles,
  • results that can fail.

Do

  • Give every variant a literal status or kind field
  • Put fields on the variant where they exist, such as data only on success

Avoid

  • Several booleans and optional fields that must be kept in sync by hand

Example

TypeScript
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function label(state: RequestState<User>): string {
  if (state.status === 'success') return state.data.name; // data exists here
  if (state.status === 'error') return state.error;
  return 'Loading';
}