A record with loading:boolean, optional data and optional error permits combinations the UI may not know how to handle: loading with old error, no data despite success, or both data and error. A discriminated state union makes the intended alternatives explicit.
Our catalogue uses idle, loading, ready and error. Loading carries a request identity; ready carries books and the identity that produced them; error carries a readable message. Each variant exposes only the fields that make sense there.
The compiler can enforce construction and safe access. It cannot decide which async response is newest or whether a request id was reused. Those are runtime transition rules. A reducer is a function that receives the current state and an action (a record describing what happened), then returns the next state without mutating the original. Separate the state shape from that transition logic. The next two lessons build small reducers.
A state union is a model, not a UI framework. This console course formats labels; a webpage could render the same state later. Keep the type small enough that every branch has an obvious meaning.
Predict, run, and explain
Write the output and trace the important state changes before checking it. Then run the program. Use Check types first; execution is allowed only when the real compiler accepts the source.
type State =
| { tag: "idle" }
| {
tag: "loading";
request: number;
}
| {
tag: "ready";
count: number;
};
function label(state: State): string {
if (state.tag === "idle") {
return "Start";
}
if (state.tag === "loading") {
return `Loading ${state.request}`;
}
return `${state.count} books`;
}
console.log(label({ tag: "idle" }));
console.log(label({ tag: "ready", count: 0 }));Enable JavaScript to edit and type-check this example.
Check your prediction
Start
0 books Read the compiler error
The tag selects a branch whose data is required.
type State =
| { tag: "idle" }
| {
tag: "ready";
title: string;
};
const state: State = { tag: "ready" };
console.log(state);Enable JavaScript to edit and type-check this example.
Check your diagnosis
Supply title for ready. Do not make the field optional merely to permit an invalid ready state.
Make invalid combinations hard to construct
A state union encodes which fields belong together. Ready requires data; error requires a message; idle needs neither. A single record of optional data and error fields permits combinations that every renderer must reinterpret.
Types establish valid shapes, while transition code establishes time and ownership. The compiler cannot choose whether old data remains visible during refresh, and it cannot know whether a numeric request id was reused. Keep those policies explicit in the state machine.
type State =
| { tag: "idle" }
| {
tag: "ready";
items: readonly string[];
}
| {
tag: "error";
message: string;
};
function label(state: State): string {
if (state.tag === "idle") {
return "Start";
}
if (state.tag === "error") {
return state.message;
}
return state.items.length === 0 ? "No matches" : state.items.join(",");
}
console.log(label({ tag: "idle" }));
console.log(label({ tag: "ready", items: [] }));Enable JavaScript to edit and type-check this example.
Check your prediction
Start
No matches The empty ready state represents a completed response, not an untouched application. Both can have zero records but need different messages.
type State =
| {
tag: "loading";
request: number;
}
| {
tag: "ready";
title: string;
};
const before: State = { tag: "loading", request: 2 };
function finish(title: string): State {
return { tag: "ready", title };
}
const after = finish("Types");
console.log(before.tag, after.tag);Enable JavaScript to edit and type-check this example.
Check your prediction
loading ready A new state object represents the transition. This toy does not check request identity; that responsibility is added by the reducer checkpoint rather than magically provided by the union.
Try the rule on new inputs
Construct one value for each state and list its required fields. Then add a refreshing state and decide whether it intentionally carries old data. Test the corresponding transition separately from construction errors.
Your turn
State Unions: practise
Define State with idle, ready carrying title:string, and error carrying message:string. Write a label function and print No selection, Types and Try again for the three variants.
// Do not model this using optional title and message on one undifferentiated record.Enable JavaScript to edit and type-check this example.
Hint 1: the idea
Each branch should have exactly the data required for its state.
Hint 2: a first step
Use tag as the shared literal field and branch before reading a variant field.
Show one solution
type State =
| { tag: "idle" }
| {
tag: "ready";
title: string;
}
| {
tag: "error";
message: string;
};
function label(state: State): string {
if (state.tag === "idle") {
return "No selection";
}
if (state.tag === "ready") {
return state.title;
}
return state.message;
}
console.log(label({ tag: "idle" }));
console.log(label({ tag: "ready", title: "Types" }));
console.log(label({ tag: "error", message: "Try again" }));Enable JavaScript to edit and type-check this example.
No selection
Types
Try againExplain why the implementation meets the stated contract. Which part is enforced by the language or checker, and which part needs behavioral evidence?
Change it and test it
Try constructing ready without title and error without message. Then propose a refreshing state and decide whether it deliberately carries previously loaded data. The compiler will check the shape you choose; it cannot choose the user experience for you.
Completion is saved in this browser only. It does not measure mastery.