Typing a data-fetching hook's idle / loading / success / error states
useResource walks a request through four states: idle, loading, success (carries data) and error (carries error). It currently exposes a flat object with three optional fields, so callers can read data while status is still loading, and TypeScript says nothing.
Retype ResourceState<T> so that data is reachable only in the success state and error only in the error state, and so that an impossible combination cannot be constructed at all. No as, no any, no non-null assertions.
type ResourceState<T> = {
status: 'idle' | 'loading' | 'success' | 'error';
data?: T;
error?: Error;
};
// your code here — retype ResourceState<T>
declare function useResource<T>(url: string): ResourceState<T>;
Write the implementation.
Make the state one discriminated union — {status:'idle'} | {status:'loading'} | {status:'success'; data:T} | {status:'error'; error:Error} — not a flat object with optional fields. Narrowing on status reaches data only in the success branch, so loading-with-data becomes inexpressible.
- ✗Modelling four states as one object with optional fields, which admits impossible combinations
- ✗Reaching for
data!in the success branch instead of letting narrowing prove it is present - ✗Assuming a generic
Tcannot live on only one member of a discriminated union
- →Why is
status: 'loading'with a populateddatafield a bug the flat type cannot catch? - →How would you add a
refetchingstate that keeps the previousdatavisible?
The solution
type ResourceState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
declare function useResource<T>(url: string): ResourceState<T>;
function View() {
const state = useResource<User>('/api/me');
switch (state.status) {
case 'idle': return null;
case 'loading': return <Spinner />;
case 'success': return <Profile user={state.data} />; // data exists, error does not
case 'error': return <Alert text={state.error.message} />;
}
}
Why the flat object is wrong
{ status; data?; error? } describes 4 statuses × 2 × 2 = 16 states, of which four are meaningful. status: 'loading' with a populated data is a perfectly legal value of that type even though no such state exists. That is where data! comes from: the type cannot prove what the programmer knows, so the programmer silences it with an assertion.
The discriminated union leaves exactly four inhabitable states. T lives happily on the success member alone — the type parameter belongs to the union as a whole, not to each of its members.
There is a bonus: exhaustiveness. Add a fifth status, forget a branch, and the never check in default refuses to compile.
default: {
const unreachable: never = state; // ❌ when a new status is added
return unreachable;
}