Model screen state as a discriminated union

TypeScript
// Every async view state is one variant, so impossible states cannot be built.
type ViewState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string; retryable: boolean };

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

function labelFor<T>(state: ViewState<T>): string {
  switch (state.status) {
    case 'idle':
      return 'Not loaded yet';
    case 'loading':
      return 'Loading...';
    case 'success':
      return 'Data ready';
    case 'error':
      return state.retryable ? `Retry: ${state.message}` : `Failed: ${state.message}`;
    default:
      return assertNever(state);
  }
}

const history: ViewState<string[]>[] = [
  { status: 'idle' },
  { status: 'success', data: ['alpha', 'beta'] },
  { status: 'error', message: 'timeout', retryable: true },
];
console.log(history.map(labelFor));