skip to content

A React component holds request state as { status, data, error } with status typed 'idle' | 'loading' | 'success' | 'error'. TypeScript still lets you read data while status is 'error'. How would you model the state so each status carries exactly the data that state has?

level: middleimportance: should knowfreq 45%

answer

  1. nullable everywhere is a smell
  2. payload belongs to its state
  3. one shared literal field discriminates
  4. switch narrows to one variant
  5. transitions replace, never spread

basics

~20 s

Model the state as a discriminated union: one object variant per status, each declaring only the fields that status owns. Narrowing on status then makes data available only in the success variant and error only in the error variant, so nullable-everywhere fields disappear.

solid answer

~40 s

The flat shape still permits nonsense — `{ status: 'success', data: null }` type-checks — and it forces every consumer to null-check `data` even in branches where the data is guaranteed. I would make the status the discriminant of a union: `{ status: 'idle' }`, `{ status: 'loading' }`, `{ status: 'success'; data: T }`, `{ status: 'error'; error: Error }`. Now the payload lives *inside* the state it belongs to. A `switch` on `state.status` narrows the type, so `state.data` is reachable only in the success arm and no optional chaining is needed. It also forces the transition to replace the whole value rather than spread a partial update, which is what actually stops the previous request's payload leaking into the next state.

code

typescript · 20 lines
typescript
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function label(state: RequestState<string[]>): string {
  switch (state.status) {
    case 'idle':
      return 'Nothing requested yet';
    case 'loading':
      return 'Loading';
    case 'success':
      return `${state.data.length} items`; // data is string[] here
    case 'error':
      return state.error.message; // error is Error here
  }
}

export { label };

go deeper

for a junior

Know the vocabulary: a status field plus per-state payload beats a bag of nullable fields. Be able to point at the success branch and say data cannot be missing there.

for a middle

Explain how the shared literal field discriminates the union and how a switch on it narrows the type, and show that the union stops partial-spread transitions from carrying stale payloads forward.

for a senior

Demonstrate that you enumerate the real states rather than copying a four-state template — call out refresh-with-previous-data as its own variant, and show exhaustiveness checking so a new state cannot silently render nothing.

for a principal

Own the consistency argument: one shared request-state type across features makes every branch reviewable and every new state a compile error rather than a support ticket. Be ready to say when that standardisation starts constraining teams unhelpfully.

## What is still wrong with the flat shape Collapsing three booleans into one `status` field removes the contradictory-flag problem, but it leaves a weaker version behind. This type: ```typescript type State<T> = { status: 'idle' | 'loading' | 'success' | 'error'; data: T | null; error: Error | null }; ``` says that *every* state may carry data and may carry an error. Two consequences follow. First, illegal values still type-check: `{ status: 'success', data: null, error: someError }` compiles fine. Second, and more annoying day to day, every consumer must defend against fields that are guaranteed present in their branch — you end up writing `state.data?.length ?? 0` inside the success case, where `data` cannot possibly be missing. ## The discriminated union A discriminated (tagged) union is a union of object types that all share one literal-typed field — the discriminant. Checking that field tells the compiler which member you are holding: ```typescript type RequestState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }; ``` The payload now belongs to the state rather than to the container. `data` exists only where it is meaningful; `error` exists only where it is meaningful; `idle` and `loading` carry nothing at all, which is exactly true of them. There is no `null` in the type, because absence is expressed by being in a different variant. ## Narrowing at the use site Inside a `switch` on the discriminant, TypeScript narrows the value to the matching member: ```typescript switch (state.status) { case 'success': return renderList(state.data); // data: T, not T | null case 'error': return renderError(state.error); // error: Error, no optional chaining default: return null; } ``` The same narrowing works with an `if (state.status === 'success')` guard. This is where most of the ergonomic win lives: the null checks were never really checking anything, they were satisfying a type that was looser than reality. You can also make the branch exhaustive. Assigning the narrowed value to a `never` in the default arm turns "someone added a fifth state" into a compile error rather than a silently missing UI: ```typescript function assertNever(x: never): never { throw new Error(`Unhandled state: ${JSON.stringify(x)}`); } ``` ## Why it changes how you write transitions With the flat shape the tempting update is a partial spread: `setState(prev => ({ ...prev, status: 'loading' }))`. That is exactly how the previous request's `data` survives into the next load. With a union you cannot spread your way from one variant to another — the compiler rejects a `loading` object that still carries a `data` field only if you declare the variants exactly, and in practice you write `setState({ status: 'loading' })`, replacing the whole value. The type nudges you toward the correct transition. This is also why the union pairs well with holding the state in a reducer: a reducer returns a fresh state object per transition by construction, and its return type being the union means every branch must produce a complete, legal state. ## Where it is worth it, and where it is not The union earns its keep when different states genuinely carry different payloads, and when several components read the state. A refresh flow is a good stress test: if your UI must show the previous list while a refresh is in flight, then "stale data during loading" is a *legal* state, and you should model it as such — for example a `{ status: 'loading'; previous?: T }` variant — instead of quietly reverting to nullable fields everywhere. The point of the exercise is to enumerate the states you actually have, not to force a textbook four. In plain JavaScript, without a type checker, the same shape is still useful as a convention — payloads live under the status they belong to, and transitions replace the object — but nothing enforces it, so the reducer becomes the only place that guarantees the invariant. That is an argument for putting the transitions in one function rather than scattering them across handlers.

  • Your UI must keep showing the previous list while a refresh is in flight. Does that break the union?
    No — it means "loading with previous data" is a legal state you had not enumerated. Model it explicitly, for example a `{ status: 'refreshing'; data: T }` variant distinct from a first-load `{ status: 'loading' }`. The mistake would be reverting every field to nullable so the flat shape can express it; that trades one honest extra variant for null checks in every branch.
  • How do you make sure a newly added state is handled everywhere?
    Give each `switch` a default arm that assigns the narrowed value to a parameter typed `never`. Once every known status is handled, the value narrows to `never` and compiles; add a fifth status and the assignment fails to type-check at every switch that has not been updated, turning a silently blank UI into a build error.
  • Is any of this worth doing in plain JavaScript with no type checker?
    The shape still helps — payloads live under the status they belong to, and transitions replace the whole object instead of spreading — but nothing enforces it. Without types the reducer becomes the only real guarantee, so put every transition there and test it as a plain function rather than relying on call sites to build legal objects.

saying these in an interview costs you the question

  • Keeps data and error nullable on every state
  • Spreads the previous state when moving to loading
  • Thinks optional chaining fixes the modelling problem
  • Says the union is only about TypeScript syntax
  • Adds a fifth field instead of a fifth variant

context