Lesson 70 of 80
The discriminated-union feature applied to one specific, extremely common shape — a UI's loading/success/error state — so a combination like 'loading and has an error' is a compile error, not a runtime footgun.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
The discriminated-unions lesson covers the general feature: a union of object shapes sharing one literal-typed field, narrowed by checking that field. This lesson is the one place that feature shows up in almost every real frontend codebase — modeling the state of an async operation (a fetch, a form submit, a file upload) as a state machine, instead of one object with a pile of optional booleans.
The naive version looks reasonable at first:
type BadState = {
isLoading: boolean;
data?: string;
error?: string;
};
Nothing here stops { isLoading: true, data: "stale", error: "network down" } from being constructed. That combination — loading, but also already has both a result and an error — is nonsense, but TypeScript has no way to reject it, because every field is independently optional. The bug isn't in any one line of logic; it's that the type itself allows a state that should never exist.
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
function render<T>(state: FetchState<T>) {
switch (state.status) {
case "idle":
return "Nothing yet.";
case "loading":
return "Loading…";
case "success":
return `Got: ${state.data}`; // ✅ state.data exists here, nowhere else
case "error":
return `Failed: ${state.message}`; // ✅ state.message exists here, nowhere else
}
}
Now there is no field called data unless status is "success", and no field called message unless status is "error". { status: "loading", error: "..." } isn't a bug you catch in review — it doesn't compile, because "loading" is a state with no error field at all. The union is the state machine: every member is one reachable state, and moving between states means constructing a different member, not mutating a shared bag of fields.
BadState produces a bug is often to add a fourth boolean or a runtime if guard; the actual fix is deleting the shared bag shape entirely in favor of separate branches."error" doesn't need a data field even typed as undefined — leaving it off entirely is what makes state.data a compile error in the error branch, not just an undefined you forgot to check.idle | selecting | uploading | success | error) is exactly as clean as the four-state version above — the pattern doesn't degrade as more states are added, unlike a growing pile of booleans.This is discriminated unions — the general feature of narrowing a whole object shape from one literal field — applied to exactly one recurring shape: an async operation's lifecycle. Nothing here is a new mechanism; it's recognizing that "loading / success / error" is a discriminant like any other, and that most of the invalid-state bugs in UI code come from not treating it as one.