Production

RemoteData grew a fifth state

TypeFirst · AgileLabs Engineering · 7 min read

Every TypeFirst codebase models async data with the classic four-state union instead of isLoading booleans:

// Core/Data/RemoteData.ts
export type RemoteData<E, T> =
  | NotAsked
  | Loading
  | Failure<E>
  | Success<T>

Four states, one exhaustive switch per view, no { data?, error?, isLoading } objects where all three are somehow set at once. If that pattern is new to you, start with impossible states — this post is about what happens the first time a system discovers the pattern is missing a state, and why that discovery is the whole argument for unions.

When HTTP 200 doesn't mean "done"

The moment any part of your product goes asynchronous — a refund handed to a payment provider, a CSV export handed to a worker, a document sent for signature — the request stops being the operation. The browser calls your API, your API queues the job, and the HTTP response only means "the request was accepted". The truth — did it actually go through? — arrives seconds later, pushed back to the browser over SSE when the worker (or the provider's webhook) reports in.

Map that onto four-state RemoteData and you hit a wall: after the HTTP response, the request isn't Loading (the call finished), and it isn't Success (nothing is confirmed). The workflow has a state the type doesn't. The boolean-flag crowd solves this with isWaitingForConfirmation next to isLoading — sixteen combinations, here we go again. The TypeFirst solution is to say the true thing:

// Core/App/ConfirmedRemoteData.ts
export type ConfirmedRemoteData<E, T> =
  | NotAsked
  | LoadingApi      // HTTP request in flight
  | WaitingConfirm  // accepted; awaiting worker confirmation via SSE
  | Failure<E>
  | Success<T>

Five states, named after reality. The button spinner and the "waiting for the provider…" indicator are different UI for different variants — not one spinner driven by two booleans that hopefully never disagree.

Timeouts are failures, as values

What if the confirmation never comes — the worker dies mid-job? The WaitingConfirm constructor arms a 30-second timer, and the timeout is not an exception, not a dangling spinner: it's a typed failure injected back into the state machine:

export type WaitingConfirmTimeout = "WAITING_CONFIRM_TIMEOUT"
export type Failure<E> = { _t: "Failure"; error: E | WaitingConfirmTimeout }

export function waitingConfirm<E, T>(
  timeoutFn: (failure: ConfirmedRemoteData<E, T>) => void,
): ConfirmedRemoteData<E, T> {
  const timer = setTimeout(
    () => timeoutFn({ _t: "Failure", error: "WAITING_CONFIRM_TIMEOUT" }),
    30_000,
  )
  return { _t: "WaitingConfirm", timer }
}

Note the error type: E | WaitingConfirmTimeout. Every view that renders a failure is forced by the compiler to decide what "we never heard back" looks like — separately from the endpoint's own business errors. That's a failure mode teams usually discover in production, staring at a spinner that never stops. Here it gets decided in a code review, because the union made it a case you have to write.

The race condition that became a switch case

Distributed systems play cruel tricks: sometimes the SSE confirmation arrives before the HTTP response that caused it. With boolean flags, that's a heisenbug. With a five-state machine, it's two lines — the SSE handler simply checks which state the submission is actually in:

// Action/Refund/SubmitRefund.ts — SSE confirmation handler
if (action._t !== "SUBMIT_REFUND" || action.crd._t !== "LoadingApi") {
  // Because SSE may come back faster than the API call
  return [authState, cmd()]
}

The guard reads like the comment. There's no flag to forget to reset, because there are no flags — there is one value, in exactly one of five states, and every transition is a pure function from one variant to the next.

The lesson: states are discovered, unions absorb discovery

Nobody designs ConfirmedRemoteData on day one. You ship with four-state RemoteData, and then the first queue-and-confirm feature reveals a fifth state that genuinely exists in the world. The union absorbs it: add a variant, and the compiler walks you to every switch that must now handle it. Total cost, one afternoon.

Now imagine retrofitting isWaitingForConfirmation into a codebase where a dozen components each combine isLoading and error in their own way. That's the difference between a model you extend and a pile of flags you excavate. Design for the states you know; pick representations that welcome the states you don't.

Related reading: making impossible states unrepresentable covers the counting discipline, and never throw explains why the timeout is a value instead of an exception. ts-bedrock ships RemoteData ready to use — and to extend.

← All posts