Modeling State Machines
Represent finite states with discriminated unions.
Modeling State Machines is a free TypeScript Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the TypeScript Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
States as a Discriminated Union
UI and async flows have distinct states: idle, loading, success, error. Modeling them as a discriminated union makes each state carry exactly the data it needs.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string };
const s: State = { status: "idle" };
console.log(s.status);Payload Per State
Only the success state has data; only error has a message. Idle and loading carry nothing extra, which prevents stale data leaking between states.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: number[] }
| { status: "error"; message: string };
const ok: State = { status: "success", data: [1, 2, 3] };
console.log(ok.status, "data" in ok ? ok.data : null);Rendering Each State
A function that switches on status can render the right output for each state, with member fields available only where they exist.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string };
function view(s: State): string {
switch (s.status) {
case "idle": return "Ready";
case "loading": return "Loading...";
case "success": return "Got: " + s.data;
case "error": return "Failed: " + s.message;
}
}
console.log(view({ status: "success", data: "users" }));Defining Transitions
A state machine moves between states via transitions. A transition function takes the current state and an event and returns the next state.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string };
function start(s: State): State {
if (s.status === "idle") return { status: "loading" };
return s;
}
console.log(start({ status: "idle" }).status);Modeling Events Too
Events themselves can be a discriminated union. Each event type triggers a specific transition.
type Event =
| { type: "FETCH" }
| { type: "RESOLVE"; data: string }
| { type: "REJECT"; message: string };
const e: Event = { type: "RESOLVE", data: "ok" };
console.log(e.type);A Full Transition Function
Combine state and event unions in one reducer. Switching on both keeps every transition explicit and type-safe.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string };
type Event =
| { type: "FETCH" }
| { type: "RESOLVE"; data: string }
| { type: "REJECT"; message: string };
function reduce(s: State, e: Event): State {
if (e.type === "FETCH") return { status: "loading" };
if (e.type === "RESOLVE") return { status: "success", data: e.data };
return { status: "error", message: e.message };
}
console.log(reduce({ status: "idle" }, { type: "FETCH" }).status);Illegal States Are Unrepresentable
You cannot have data and an error message at the same time. The union design forbids contradictory states by construction.
type State =
| { status: "success"; data: string }
| { status: "error"; message: string };
// const bad: State = { status: "success", data: "x", message: "y" };
// Allowed extra props would be flagged in strict object checks.
console.log("no contradictory states");Guarding Invalid Transitions
You can ignore events that do not apply to the current state, keeping the machine in a consistent state instead of crashing.
type State = { status: "idle" } | { status: "loading" };
type Event = { type: "FETCH" } | { type: "CANCEL" };
function reduce(s: State, e: Event): State {
if (s.status === "loading" && e.type === "CANCEL") return { status: "idle" };
if (s.status === "idle" && e.type === "FETCH") return { status: "loading" };
return s; // ignore invalid combos
}
console.log(reduce({ status: "loading" }, { type: "CANCEL" }).status);Adding Exhaustiveness
Pair the reducer with assertNever so adding a new state or event surfaces every place you must update.
function assertNever(x: never): never { throw new Error("unhandled"); }
type State = { status: "idle" } | { status: "loading" } | { status: "done"; data: string };
function view(s: State): string {
switch (s.status) {
case "idle": return "idle";
case "loading": return "loading";
case "done": return s.data;
default: return assertNever(s);
}
}
console.log(view({ status: "done", data: "ok" }));Initial and Final States
Designate a clear starting state (often idle) and terminal states (success or error). This documents the machine lifecycle.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string };
const initial: State = { status: "idle" };
console.log("Start:", initial.status);A Complete Mini State Machine
Here is the whole loop: start idle, fetch to loading, resolve to success. The types keep every step honest.
type State = { status: "idle" } | { status: "loading" } | { status: "success"; data: string };
type Event = { type: "FETCH" } | { type: "RESOLVE"; data: string };
function reduce(s: State, e: Event): State {
if (e.type === "FETCH") return { status: "loading" };
return { status: "success", data: e.data };
}
let st: State = { status: "idle" };
st = reduce(st, { type: "FETCH" });
st = reduce(st, { type: "RESOLVE", data: "hi" });
console.log(st.status, st.status === "success" ? st.data : "");Quick Check: State Machines
Test your understanding of modeling state machines.
Recap: Modeling State Machines
You modeled idle/loading/success/error as a discriminated union, wrote type-safe transitions over state and event unions, and used exhaustiveness to keep the machine correct as it grows.
type State = { status: "idle" } | { status: "loading" } | { status: "success"; data: string };
const s: State = { status: "success", data: "done" };
console.log(s.status === "success" ? s.data : "");Frequently asked questions
Is the “Modeling State Machines” lesson free?
Yes — the full text of “Modeling State Machines” is free to read here on the web, and the TypeScript Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the TypeScript Academy course, upgrade to CoddyKit PRO.
What will I learn in “Modeling State Machines”?
Represent finite states with discriminated unions. You practise TypeScript Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start TypeScript Academy?
No prior experience is required. TypeScript Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Modeling State Machines” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this TypeScript Academy lesson?
Yes. Every TypeScript Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Building Discriminated Unions
- Narrowing on the Discriminant
- Exhaustiveness Checking with never
- Modeling State Machines