You are handed the TypeScript type `{ isLoading: boolean; data?: User[]; error?: Error }` used to represent the state of an async request, and asked to make illegal states unrepresentable. How would you redesign it, and what does the change buy the call sites?
answer
- count the combinations the type allows
- optional fields hide a correlation
- move the payload onto the state that owns it
- required data means no non-null assertions
- stale-data refresh is its own state
basics
~20 sReplace the flag bag with a union tagged by a status literal — idle, loading, success carrying required data, error carrying required error. Combinations like loading-with-an-error stop being constructible, and call sites lose their optional-field guesswork.
solid answer
~50 sThe original type has eight constructible combinations and only about four meaningful ones: it permits `isLoading: true` *with* an error, and success with `data` missing. Every consumer therefore has to guess an order of checks, and reaches for `data!` or `data?.length ?? 0` to get past the optionality. I would model the four real states as a discriminated union: `{ status: 'idle' } | { status: 'loading' } | { status: 'success'; data: User[] } | { status: 'error'; error: Error }`. Now `data` is *required* on the one member that has it, so no non-null assertions; the impossible combinations fail at construction, not in review; and one check on `status` narrows the value so the payload is typed in each branch. The cost is honesty about transitions — if the UI keeps stale data while refetching, that is a real state and needs its own member, such as `{ status: 'refreshing'; data: User[] }`.
code
typescript · 19 linesinterface User { id: number; name: string }
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: Error };
function describe(s: RequestState): string {
switch (s.status) {
case 'idle': return 'Nothing yet';
case 'loading': return 'Loading…';
case 'success': return `${s.data.length} users`;
case 'error': return s.error.message;
}
}
console.log(describe({ status: 'success', data: [{ id: 1, name: 'Ada' }] }));
console.log(describe({ status: 'error', error: new Error('offline') }));go deeper
Be able to write out the four-member union and point out that data is required on the success member, which is why consumers stop needing data! or optional chaining.
Explain why the compiler cannot infer that !isLoading && !error implies data is present: the correlation lives in prose, not in the type. Show how moving each payload onto its own member encodes it.
Count the illegal combinations out loud, propose the union, then name a real cost — the refetch-with-stale-data case that needs its own member, and the fact that every construction site must now write a whole state.
Frame it as a policy: correlated optional fields are a modelling defect, and states are values rather than independent flags. Be ready to argue the migration cost and to say where a closed state union stops being the right shape.
## Read the type as a state space The first move is arithmetic, not typing. `{ isLoading: boolean; data?: User[]; error?: Error }` has 2 × 2 × 2 = 8 inhabitable combinations once you count each optional field as present-or-absent. How many are meaningful? Roughly four: nothing has happened yet, a request is in flight, it succeeded with data, it failed with an error. The other four are *illegal states* the type happily lets you build: - `isLoading: true` together with an `error` — is it still loading, or did it fail? - `isLoading: false` with neither `data` nor `error` — idle, or a success that forgot its payload? - `data` and `error` both present — which one wins? - `isLoading: false` with both absent after a request finished — a bug nothing catches. A type that admits states your code cannot handle pushes the handling into every consumer. That is the defect being probed here. ## The redesign ```ts type RequestState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: User[] } | { status: 'error'; error: Error }; ``` Three design decisions are doing the work: 1. **One literal-typed tag, required on every member.** `status` is a closed set of string literals, so a single comparison identifies the member. 2. **Payloads live on the member that owns them, and are required there.** `data` is not optional on `success` — a success without data cannot be constructed. This is what removes `data!` and `data?.` from consumers. 3. **No payload appears on a member that has no business carrying it.** `loading` has no `error` field at all, so the illegal combination has no syntax. ## What the call sites gain ```ts function render(s: RequestState): string { switch (s.status) { case 'idle': return 'Nothing yet'; case 'loading': return 'Loading…'; case 'success': return `${s.data.length} users`; // data: User[], no ! needed case 'error': return s.error.message; // error: Error } } ``` Each branch has exactly the fields that state actually has. Compare that with the flag bag, where the success branch still types `data` as `User[] | undefined` no matter how many booleans you tested first — the compiler cannot know that `!isLoading && !error` implies `data` is present, because *nothing in the type says so*. The union encodes the implication; the boolean bag only hints at it in prose. A second gain: construction becomes self-documenting. `setState({ status: 'error', error: e })` is a complete, valid state in one expression. With the flag bag, callers must remember to also clear `isLoading` and clear stale `data`, and forgetting is silent. ## The honest costs **Transitions that carry data.** Many real UIs want to show the previous list while a refetch is in flight. In the flag bag that was accidentally expressible (`isLoading: true` *and* `data` present); in the union it is not — which is correct, but it means you must name the state you actually have: ```ts type RequestState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'refreshing'; data: T } | { status: 'success'; data: T } | { status: 'error'; error: Error; lastData?: T }; ``` That is the refactor paying off: the ambiguous combination becomes a deliberate, documented state rather than a coincidence of two booleans. **Every construction site must set the tag.** Partial updates (`setState({ isLoading: false })`) no longer typecheck, because a state is now a whole value rather than three independent fields. That is the point, but it does mean touching every writer. **Adding a variant is a breaking change on purpose.** Introducing `'cancelled'` makes every exhaustive consumer fail to compile until it is handled. Treat that as the feature you bought. ## Generalising the pattern The smell to recognise is *parallel optionals*: two or more fields that are only ever meaningful together, or only ever meaningful apart, with the correlation living in a comment. Other instances: `{ isAdmin: boolean; permissions?: string[] }`, `{ paid: boolean; paidAt?: Date; refundReason?: string }`, `{ type: string; url?: string; file?: File }`. Each becomes a small tagged union where the correlated fields move onto the member that requires them. When you present this in an interview, lead with the count of illegal combinations, then show the union, then name one cost — the refetch-with-stale-data state is the cost interviewers are waiting to hear, because it proves you have shipped this rather than read about it.
- The team objects that the union forces them to rewrite every partial state update. How do you answer that?That objection is the bug restated. With three independent fields, a partial update can leave `isLoading: false` alongside stale `data` and a stale `error`, and nothing flags it. Making state a whole value means every write states which of the four situations now holds. The rewrite is mechanical — usually one `setState({ status: … })` per call site — and it is where the silent inconsistencies surface.
- Where does the discriminated union stop helping — is data fetched from the network safe once it is typed this way?No. The union constrains code that *constructs* the state inside your program; it says nothing about a value that arrived as JSON. Types are erased, so a parsed response is whatever the bytes contained. Validate at the boundary and build the union member from the validated result, so the tag is set by code you control rather than trusted from the wire.
- Would you use a boolean tag like `ok: true | false` instead of a string `status`?For exactly two outcomes it is fine and reads well. Here there are four states and a plausible fifth, so a string tag is the better call: it names each state in logs and stack traces, extends without reshaping the type, and avoids the awkward moment when a third state forces a second boolean — which is how flag bags start.
- How do you keep this ergonomic when the same state shape repeats for many resources?Make it generic in the payload: `type RequestState<T> = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }`, then use `RequestState<User[]>`, `RequestState<Order>`, and so on. The tag stays a fixed literal set, so narrowing behaves identically at every instantiation.
saying these in an interview costs you the question
- Keeps the booleans and adds runtime asserts instead
- Says data! is fine because we always check isLoading first
- Marks the payload optional on the success member too
- Claims the union validates data coming from the server
- Adds a fifth boolean rather than naming the state