Model Loading, Success, and Error States with Unions
Use discriminated unions to model async states like loading, success, and error in TypeScript. Learn the pattern, render each state safely, and add exhaustiveness checking.
Modeling async states with isLoading, error, and data as separate fields is error-prone. Nothing stops isLoading from being true at the same time error is set, or data from being accessed while still loading. A discriminated union eliminates these impossible states.
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };Each variant has exactly one status value and only the data that belongs to that status. You cannot access data while loading, because the loading variant does not have a data property at all.
Why Separate Flags Fail
Before reaching for a discriminated union, most developers try modeling async state with a handful of independent fields instead. The common alternative uses separate booleans and an optional data field:
// Problematic approach
interface BadState<T> {
isLoading: boolean;
error: string | null;
data: T | null;
}Nothing in this shape prevents setting fields that contradict each other, so a value like the one below is perfectly valid as far as the type checker is concerned:
const state: BadState<string[]> = {
isLoading: true,
error: "Network failed",
data: ["stale", "data"]
};
// Is it loading? Did it error? Is the data fresh?The discriminated union prevents this: a value is exactly one state at a time.
The diagram shows valid transitions. The type enforces them: you cannot jump from idle to success without going through loading.
Defining the State Type
The AsyncState type from the introduction is generic: the type parameter stands for the shape of whatever data you are fetching. Applying it to a real resource is a matter of passing that resource's type in:
type User = { id: string; name: string };
type UserState = AsyncState<User[]>;Now UserState can be idle, loading, have an array of users on success, or have an error message.
Building a Fetch Function
Write a function that transitions through the states. The happy path checks the response status and returns either a success or an error variant:
async function fetchUsers(): Promise<AsyncState<User[]>> {
const response = await fetch("/api/users");
if (!response.ok) {
return { status: "error", message: `HTTP ${response.status}` };
}
const data: User[] = await response.json();
return { status: "success", data };
}A network failure throws before fetch even resolves, so that case needs its own handling wrapped around the same logic, separate from the HTTP-status check above:
async function fetchUsersSafely(): Promise<AsyncState<User[]>> {
try {
return await fetchUsers();
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "Unknown error" };
}
}Each return path produces exactly one variant. There is no need to manage separate booleans.
Rendering Each State
Pair the render function with the same assertNever helper used for exhaustiveness checking elsewhere, so a missing case fails to compile instead of failing silently:
function assertNever(value: never): never {
throw new Error(`Unexpected state: ${JSON.stringify(value)}`);
}A render function switches on the discriminant and calls the helper from the default branch, the same exhaustiveness pattern used throughout this section:
function render(state: UserState): string {
switch (state.status) {
case "idle": return "Ready to load users.";
case "loading": return "Loading users...";
case "success": return `Loaded ${state.data.length} users: ${state.data.map(u => u.name).join(", ")}`;
case "error": return `Failed to load: ${state.message}`;
default: return assertNever(state);
}
}Inside the success case, its data field is accessible and typed as an array of users. Inside the error case, its message field is accessible and typed as a string. The compiler enforces this correctly.
const idle: UserState = { status: "idle" };
console.log(render(idle)); // "Ready to load users."
const success: UserState = { status: "success", data: [{ id: "1", name: "Alice" }] };
console.log(render(success)); // "Loaded 1 users: Alice"
const error: UserState = { status: "error", message: "Network timeout" };
console.log(render(error)); // "Failed to load: Network timeout"Adding an Empty State
A common extension is an empty state for when the request succeeds but returns no data, since that is meaningfully different from still loading or from a real success with items:
type AsyncStateWithEmpty<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "empty" }
| { status: "error"; message: string };With exhaustiveness checking, adding the empty variant immediately produces a compile error in every switch statement on this type, since the earlier render function no longer handles every variant. Adding the missing case fixes it:
function render(state: AsyncStateWithEmpty<User[]>): string {
switch (state.status) {
case "idle": return "Ready to load users.";
case "loading": return "Loading users...";
case "success": return `Loaded ${state.data.length} users.`;
case "empty": return "No users found.";
case "error": return `Failed to load: ${state.message}`;
default: return assertNever(state);
}
}The compiler guarantees you do not forget the new case, even in files you have not touched in months.
Using the Pattern in UI Code
This pattern is not tied to any specific framework. A UI layer built with React, Vue, or plain DOM code can switch on the same status discriminant, returning a button in the idle branch, a spinner in the loading branch, and the rendered list only inside the success branch. Whatever the framework, the same rule holds: the success branch is the only place the data field is accessible, and TypeScript prevents accessing it anywhere else.
Combining States Manually
If you manage state manually instead of returning from a single function, declare a module-level variable typed as the union:
let userState: UserState = { status: "idle" };Each step of the request reassigns the whole variable to a new variant, rather than mutating individual fields on the existing object in place:
async function loadUsers() {
userState = { status: "loading" };
try {
const response = await fetch("/api/users");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: User[] = await response.json();
userState = { status: "success", data };
} catch (err) {
userState = { status: "error", message: err instanceof Error ? err.message : "Unknown" };
}
}Each assignment replaces the entire state with a new variant. Old data is gone. The type tracks exactly which state is current.
Common Mistakes
Adding data to the loading variant. If you put a data field on the loading variant to hold stale data while refetching, you lose the guarantee that any data present is actually fresh. Use a separate refetching variant instead if you need to preserve old data during a reload.
Using the state type without exhaustiveness checking. Without the assertNever helper, new variants silently fall through. Always add the default branch.
Returning the state type from a function that does not handle all paths. If a path does not return, TypeScript infers undefined for that path, and the function's inferred return type silently gains undefined as an extra member. Use an explicit return type to catch this at the declaration instead of at the call site.
Forgetting that error is a terminal state. Once in the error state, the only way out is a transition back to loading on retry. Do not set data and error at the same time.
For the underlying pattern, see discriminated unions. For guaranteeing every state is rendered, see exhaustive checks.
Rune AI
Key Insights
- Model async states as a discriminated union with a shared status discriminant.
- Each variant (idle, loading, success, error) carries only its relevant data.
- This prevents impossible states like being both loading and successful at once.
- Switch on the discriminant to render each state with type-safe data access.
- Add exhaustiveness checking so new states produce compile errors until handled.
Frequently Asked Questions
How do I model loading, success, and error states in TypeScript?
Why use a discriminated union instead of separate boolean flags?
Can I add more states like 'empty' or 'cached'?
Conclusion
A discriminated union with idle, loading, success, and error states eliminates impossible states from your async code. Each variant carries only the data it needs. Switch on the discriminant, render each state, and add exhaustiveness checking to stay safe as states evolve.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.