Skip to content
Chapter 5Lesson 2

States plus transitions

Model TypeScript state machines as discriminated unions paired with typed transition functions, so the compiler rejects illegal state changes the way the previous lesson rejected illegal fields.

The previous lesson installed the discriminated-union shape: a request state that admits exactly four variants, with data living only on success and error living only on error. The variants are watertight. A consumer can’t read a field that doesn’t exist on the variant in hand, and the type collapses the many combinations the flat shape allowed down to the four states the runtime actually inhabits.

That shape leaves one gap. The variants are watertight, but the transitions between them are not. A discriminated union is a static shape, so it says nothing about which variant can follow which. Code anywhere in the codebase can construct any variant from any other variant, and the compiler raises no objection. Nothing in the type stops a value at error from being replaced by loading, nothing stops success from being overwritten by idle, and nothing stops a piece of state-management code from reviving a state the user already canceled.

Picture a file upload. The user picks a 200 MB video and the request starts, so the fetch is in flight, sending bytes. Halfway through, the user clicks Cancel and the upload returns to idle. Two seconds later a stale onProgress callback from the original fetch fires. The network was already running when the user clicked, so the callback had already been scheduled and runs anyway. The callback constructs a new { kind: 'uploading', progress: 50 }, and the UI snaps back to “uploading…” on an upload the user just canceled. The progress bar marches forward against a request the user has no idea is still being tracked.

The mutator that ships this bug takes whatever state is current along with a progress number, and constructs an uploading variant unconditionally:

type UploadState =
| { kind: 'idle' }
| { kind: 'uploading'; progress: number }
| { kind: 'done'; url: string };
const onProgress = (state: UploadState, progress: number): UploadState => {
return { kind: 'uploading', progress };
};

The function compiles, it reads cleanly, and it even looks defensible: given a progress event, return an uploading state. But it’s wrong, because the input type accepts every variant and the body returns uploading regardless. The compiler accepts a transition from idle or done straight back into uploading, because the static union carries no record of which transitions are legal.

The fix is to type the transition functions so the only way to reach uploading is from idle, through start. Then the stale callback’s attempt to revive a canceled upload fails at compile time. That is the subject of this lesson.

What a state machine adds to a discriminated union

Section titled “What a state machine adds to a discriminated union”

A state machine is a discriminated union of states plus a set of typed transition functions, where each function takes a specific state (or set of states) as input and returns a specific state as output. The union models which states exist; the transition functions model which state can follow which. That second part is the new piece.

A transition function isn’t a generic mutator like the buggy onProgress above. Its signature names the variant it accepts and the variant it returns, and the body stays small because the signature does the work. When a caller passes the wrong input state, the compile error fires at the call site, the moment the bug is written, rather than at runtime or in code review.

The contract fits in one sentence: the union models which states exist, and the transition functions model which transitions are valid. The rest of the lesson is a single worked example that makes that sentence concrete.

State machines read more easily as pictures than as types. Nodes are variants, edges are transitions, and the verbs sit on the edges. Before writing any TypeScript, an experienced engineer usually sketches something like this on a whiteboard, because a handful of labeled edges make the rules of the system legible at a glance.

stateDiagram-v2
  direction LR
  [*] --> idle
  idle --> uploading : start()
  uploading --> done : succeed(url)
  uploading --> error : fail(err)
  uploading --> idle : cancel()
  error --> idle : reset()
  done --> [*]
The upload machine: five labeled transitions. The `progress` self-loop is omitted; the code below covers it.

The diagram lists five edges and skips a sixth. progress is a self-loop on uploading: a state-preserving update where the progress number changes but the variant doesn’t. Mermaid renders self-loops poorly, so the picture omits it and the code covers it instead. Nothing about the transition is conceptually different from the others.

Now translate the picture into types. The states come first, as a discriminated union: exactly the shape from the previous lesson, with each variant carrying the data that variant is valid with.

type UploadState =
| { kind: 'idle' }
| { kind: 'uploading'; progress: number; controller: AbortController }
| { kind: 'done'; url: string }
| { kind: 'error'; error: Error };

Four variants. kind is the discriminant, the convention for general taxonomies that the previous lesson named, and it’s a deliberate departure from that lesson’s status choice. status belongs to a single request’s lifecycle, the four-stage idle | loading | success | error shape. kind belongs to a broader feature lifecycle that the application orchestrates over time: an upload, an optimistic mutation, anything the user steers through multiple stages. The per-state data sits inside each variant: a progress number and an AbortController on uploading, a url on done, an error on error. idle carries nothing because there’s nothing for it to carry.

AbortController is the standard Web API for canceling in-flight network requests, and a chapter later in this unit covers its mechanics. Here it’s a typed field that lives only on uploading, because uploading is the only state with a request to abort.

Now the transitions. Each function’s signature names one source variant and one destination variant, using the intersection form to pin the input type to a specific kind. Walk through them one at a time, because the differences between the transitions are where the lesson lives.

const start = (
state: UploadState & { kind: 'idle' },
): UploadState & { kind: 'uploading' } => ({
kind: 'uploading',
progress: 0,
controller: new AbortController(),
});
const progress = (
state: UploadState & { kind: 'uploading' },
next: number,
): UploadState & { kind: 'uploading' } => ({
...state,
progress: next,
});
const succeed = (
state: UploadState & { kind: 'uploading' },
url: string,
): UploadState & { kind: 'done' } => ({ kind: 'done', url });
const fail = (
state: UploadState & { kind: 'uploading' },
error: Error,
): UploadState & { kind: 'error' } => ({ kind: 'error', error });
const cancel = (
state: UploadState & { kind: 'uploading' },
): UploadState & { kind: 'idle' } => {
state.controller.abort();
return { kind: 'idle' };
};
const reset = (
state: UploadState & { kind: 'error' },
): UploadState & { kind: 'idle' } => ({ kind: 'idle' });

start, idle to uploading. This is the simplest transition. The intersection form on the parameter (UploadState & { kind: 'idle' }) names the variant the function accepts, and the return type (UploadState & { kind: 'uploading' }) names the variant it produces. The body constructs a fresh AbortController so a later cancel has something to call .abort() on. Every step that follows mirrors this signature shape.

const start = (
state: UploadState & { kind: 'idle' },
): UploadState & { kind: 'uploading' } => ({
kind: 'uploading',
progress: 0,
controller: new AbortController(),
});
const progress = (
state: UploadState & { kind: 'uploading' },
next: number,
): UploadState & { kind: 'uploading' } => ({
...state,
progress: next,
});
const succeed = (
state: UploadState & { kind: 'uploading' },
url: string,
): UploadState & { kind: 'done' } => ({ kind: 'done', url });
const fail = (
state: UploadState & { kind: 'uploading' },
error: Error,
): UploadState & { kind: 'error' } => ({ kind: 'error', error });
const cancel = (
state: UploadState & { kind: 'uploading' },
): UploadState & { kind: 'idle' } => {
state.controller.abort();
return { kind: 'idle' };
};
const reset = (
state: UploadState & { kind: 'error' },
): UploadState & { kind: 'idle' } => ({ kind: 'idle' });

progress, uploading to uploading. The state-preserving update: same variant, updated progress field. The second positional parameter next: number is the event payload. This is the function the introduction’s bug tried to be, written correctly. The signature refuses any input that isn’t uploading, so a stale callback can no longer construct an uploading state from idle or done. The bug from the introduction stops compiling.

const start = (
state: UploadState & { kind: 'idle' },
): UploadState & { kind: 'uploading' } => ({
kind: 'uploading',
progress: 0,
controller: new AbortController(),
});
const progress = (
state: UploadState & { kind: 'uploading' },
next: number,
): UploadState & { kind: 'uploading' } => ({
...state,
progress: next,
});
const succeed = (
state: UploadState & { kind: 'uploading' },
url: string,
): UploadState & { kind: 'done' } => ({ kind: 'done', url });
const fail = (
state: UploadState & { kind: 'uploading' },
error: Error,
): UploadState & { kind: 'error' } => ({ kind: 'error', error });
const cancel = (
state: UploadState & { kind: 'uploading' },
): UploadState & { kind: 'idle' } => {
state.controller.abort();
return { kind: 'idle' };
};
const reset = (
state: UploadState & { kind: 'error' },
): UploadState & { kind: 'idle' } => ({ kind: 'idle' });

succeed and fail, uploading to done and uploading to error. Two parallel transitions, both from uploading, each mapping to its terminal variant and dropping the controller along the way. The request has resolved, or failed at the server, so there’s nothing left to abort.

const start = (
state: UploadState & { kind: 'idle' },
): UploadState & { kind: 'uploading' } => ({
kind: 'uploading',
progress: 0,
controller: new AbortController(),
});
const progress = (
state: UploadState & { kind: 'uploading' },
next: number,
): UploadState & { kind: 'uploading' } => ({
...state,
progress: next,
});
const succeed = (
state: UploadState & { kind: 'uploading' },
url: string,
): UploadState & { kind: 'done' } => ({ kind: 'done', url });
const fail = (
state: UploadState & { kind: 'uploading' },
error: Error,
): UploadState & { kind: 'error' } => ({ kind: 'error', error });
const cancel = (
state: UploadState & { kind: 'uploading' },
): UploadState & { kind: 'idle' } => {
state.controller.abort();
return { kind: 'idle' };
};
const reset = (
state: UploadState & { kind: 'error' },
): UploadState & { kind: 'idle' } => ({ kind: 'idle' });

cancel, uploading to idle. This is the only transition in the machine with a side effect. The body calls .abort() on the controller before returning the new state. The side effect belongs here because it’s part of what cancel means: a cancel that didn’t abort the network request would leave bytes still flowing. When a machine’s effects grow larger or more orchestrated, such as retries, timers, and downstream calls, the production patterns for handling them land in later chapters.

const start = (
state: UploadState & { kind: 'idle' },
): UploadState & { kind: 'uploading' } => ({
kind: 'uploading',
progress: 0,
controller: new AbortController(),
});
const progress = (
state: UploadState & { kind: 'uploading' },
next: number,
): UploadState & { kind: 'uploading' } => ({
...state,
progress: next,
});
const succeed = (
state: UploadState & { kind: 'uploading' },
url: string,
): UploadState & { kind: 'done' } => ({ kind: 'done', url });
const fail = (
state: UploadState & { kind: 'uploading' },
error: Error,
): UploadState & { kind: 'error' } => ({ kind: 'error', error });
const cancel = (
state: UploadState & { kind: 'uploading' },
): UploadState & { kind: 'idle' } => {
state.controller.abort();
return { kind: 'idle' };
};
const reset = (
state: UploadState & { kind: 'error' },
): UploadState & { kind: 'idle' } => ({ kind: 'idle' });

reset, error to idle. The recovery edge: the user dismisses the error and the machine returns to idle, ready to start over. This is the only transition function whose input variant isn’t uploading or idle.

1 / 1

Read those signatures once more without the surrounding prose. The input type names the variant the function accepts, and the output type names the variant it produces. The diagram on the whiteboard and the six TypeScript signatures are two views of the same machine: nodes become variants, and edges become function signatures.

Look back at UploadState. The controller lives on uploading only, the url on done only, the error on error only, and the progress on uploading only. Each state holds exactly the data that state is valid with, no more and no less. The name for this is per-state invariants, and it’s the structural rule that makes the pattern pay off across a whole codebase.

What you gain is a compile-time guarantee: the caller cannot read url on uploading or controller on idle. The invariants live in the type, not in a runtime null-check the consumer might forget. Compare the shape this lesson teaches with the shape it forbids, the flat-with-optionals form, which flattens every variant’s data onto a shared object with everything optional.

type UploadState =
| { kind: 'idle' }
| { kind: 'uploading'; progress: number; controller: AbortController }
| { kind: 'done'; url: string }
| { kind: 'error'; error: Error };

Each variant carries its own fields and nothing more. The compiler refuses state.url on uploading and state.controller on idle because those fields don’t exist on those variants. The invariants are part of the type, so no runtime check is required.

The same per-state-invariant discipline shows up again in TanStack Query mutation states later in the course, and in Stripe’s subscription status, which you’ll see at the end of this lesson. When you read a real production type alias and notice the variant-specific fields living inside variants rather than as top-level optionals, that’s this rule at work.

The intersection form UploadState & { kind: 'idle' } does the work. It’s narrower than UploadState, because its only inhabitants are values whose kind is 'idle'. When start declares its parameter as UploadState & { kind: 'idle' }, the compiler refuses any caller that hands it a value of the wider UploadState, because that wider type includes variants start can’t accept.

That refusal is what enforces the machine. Here is what it looks like at a call site:

declare const currentState: UploadState;
// Compile error: Argument of type 'UploadState' is not assignable to
// parameter of type 'UploadState & { kind: "idle" }'.
const next = start(currentState);

The error message names the gap exactly: UploadState is wider than the function wants. The caller has a value that might be uploading, done, or error, and start only accepts idle. The fix is the narrowing form from the previous lesson, narrow first and then call.

if (currentState.kind === 'idle') {
const next = start(currentState);
// currentState narrowed to { kind: 'idle' } inside this block
}

Inside the if, the compiler narrows currentState to UploadState & { kind: 'idle' }, which matches start’s input type exactly. Outside the block, currentState widens back. The pattern composes well: a UI component reading a state, deciding what transitions to offer, narrows once and dispatches to the matching transition function.

This is where the introduction’s bug is eliminated. The stale onProgress callback can’t construct { kind: 'uploading' } from a canceled state, because the only function that produces uploading is start, and start only accepts idle. The callback never gets the chance to revive the upload, because the call that would do so won’t compile. Notice the shape of the fix: six small transition functions, each with one source variant and one destination, instead of one “smart” mutator that branches internally on the input state. The split-function form is the default because each signature reads as a one-line contract: this state, then that state.

State machines aren’t a niche pattern. The same shape, a discriminated union plus typed transitions, fits a surprising number of SaaS features once you have the eye for it. Three of them recur often enough to be worth meeting now.

The first is the upload machine you just built. The same machine ships in the R2 presigned-PUT flow in later chapters: the destination changes, but the machine doesn’t.

The second is optimistic mutation, the shape every TanStack Query mutation operates against. The user types a new value, the UI shows it immediately so the form feels instant, and the network call goes out in the background. If the server accepts, the optimistic value is confirmed. If the server rejects, the UI rolls back to the original.

type MutationState =
| { kind: 'idle' }
| { kind: 'optimistic'; pending: string; original: string }
| { kind: 'confirmed'; value: string }
| { kind: 'failed'; original: string; error: Error };
declare const apply: (
state: MutationState & { kind: 'idle' },
original: string,
pending: string,
) => MutationState & { kind: 'optimistic' };
declare const confirm: (
state: MutationState & { kind: 'optimistic' },
value: string,
) => MutationState & { kind: 'confirmed' };
declare const rollback: (
state: MutationState & { kind: 'optimistic' },
error: Error,
) => MutationState & { kind: 'failed' };

The per-state invariant earns its keep here. The optimistic variant must carry the original value, because that’s what rollback restores if the server rejects. The confirmed variant doesn’t need original, since there’s no rollback after success. The failed variant carries original again so the UI can show a message like “we couldn’t save your change, reverting to the previous value.” The shape encodes the safety property: rollback is only callable on a state that remembers what to roll back to.

The values are typed as string here for the survey. In production this generalizes, because any entity can be mutated optimistically, but the generics that express that land in a later lesson of this chapter. The pattern this lesson teaches is the intersection-with-discriminant form, and pinning the value type to string keeps the focus there.

The third is the Stripe subscription lifecycle. The variants here aren’t chosen by the application; they’re dictated by Stripe’s API. The job is to model them as a discriminated union with the per-variant fields Stripe actually sends.

type SubscriptionState =
| { status: 'trialing'; trialEndsAt: Date }
| { status: 'active'; currentPeriodEnd: Date }
| { status: 'past_due'; nextRetryAt: Date }
| { status: 'canceled'; canceledAt: Date }
| { status: 'incomplete'; latestInvoiceId: string };

Five variants, five per-state invariants. trialing carries a trialEndsAt because the upsell timer in the UI reads it. past_due carries a nextRetryAt because the dunning surface needs to tell the customer when Stripe will try the card again. canceled carries a canceledAt because audit logs and the UI both want the timestamp. incomplete carries the latestInvoiceId so the recovery flow can reach the right invoice. The variants use status as the discriminant, not kind, because Stripe’s API uses status: match the wire vocabulary rather than fight it.

There’s a subtle difference here. In the upload machine, the application owns the transitions and writes them. In the subscription machine, the transitions are owned by Stripe, so the application doesn’t write cancel(subscription) or markPastDue(subscription). Instead, it receives webhook events that describe transitions Stripe has already performed, and its job is to validate those events and project them into a stored state. The general rule: when an external system dictates the transitions, the application models the states and validates the incoming transitions, but it does not author the transition functions. The webhook handler that triggers the projection lands in the Stripe billing chapter.

The signatures you’ve been reading are standalone transition functions: one function per transition, each with its own name and a signature that documents one source-to-destination pair. The lesson defaults to this form because each signature reads as a contract.

There’s a second form you’ll meet in React’s useReducer and in Zustand stores: the reducer. A reducer is one function with the signature (state: State, event: Event) => State. It switches on the event’s type discriminant internally and routes to the right transition body in each branch. The reducer form earns its weight when a framework expects that signature: useReducer dispatches actions through a single reducer, and Zustand stores compose around the same shape. Standalone functions and reducers are two encodings of the same machine. This lesson uses the standalone form for legibility, and you’ll meet the reducer form when the React unit covers useReducer and when Zustand lands in a later unit.

The plain-TypeScript form, a discriminated union plus typed transition functions, covers around 90% of SaaS state-machine needs. There is a threshold where it stops scaling, though, and it’s worth naming.

XState v5 earns its weight in a few situations. The first is a machine with more than five or so states and branching transitions. The second is parallel states, meaning independently progressing regions, such as a wizard with three concurrent form sections, each with its own lifecycle. The third is history nodes, where the user digresses and the machine resumes from where they left off. The fourth is the Stately Studio inspector, which visualizes live machines during development and pays for itself when onboarding new engineers to a complex flow. XState also ships first-class actor support and built-in typed async, which matter once the machine orchestrates more than synchronous state changes.

The course commits to the plain-TS form. XState is named once here so you recognize it as the next tool up and know roughly where the threshold sits; the course’s projects stay below it.

Exercise: type the optimistic-mutation transitions

Section titled “Exercise: type the optimistic-mutation transitions”

The optimistic-mutation machine above had three transitions written out and one left implied. Type the four transitions so the call-site queries resolve to the right output variants, using the intersection form Mutation & { kind: 'idle' } (and its siblings) to name the input and output variants.

The @ts-expect-error directives at the bottom mark illegal calls that must stay illegal, so your typings should keep them firing. If your types are right and the directives end up unused, the harness will tell you.

Type the four transitions so each call-site query resolves to the right output variant. Use the intersection form `Mutation & { kind: 'idle' }` (and its siblings) to name the input and output variants. The `@ts-expect-error` directives at the bottom must keep firing — the illegal calls have to stay illegal.

  • Type query at line 19 must resolve to a type containing optimistic
  • Type query at line 21 must resolve to a type containing confirmed
  • Type query at line 23 must resolve to a type containing failed
  • Type query at line 25 must resolve to a type containing optimistic
Booting type-checker…
Reveal the reference solution
const apply = (
state: Mutation & { kind: 'idle' },
original: string,
pending: string,
): Mutation & { kind: 'optimistic' } => ({
kind: 'optimistic',
pending,
original,
});
const confirm = (
state: Mutation & { kind: 'optimistic' },
value: string,
): Mutation & { kind: 'confirmed' } => ({ kind: 'confirmed', value });
const rollback = (
state: Mutation & { kind: 'optimistic' },
error: Error,
): Mutation & { kind: 'failed' } => ({
kind: 'failed',
original: state.original,
error,
});
const retry = (
state: Mutation & { kind: 'failed' },
): Mutation & { kind: 'optimistic' } => ({
kind: 'optimistic',
pending: state.original,
original: state.original,
});

Each signature names one source variant on the parameter and one destination variant on the return. rollback reads state.original because the optimistic variant carries it, and retry reads state.original because the failed variant carries it. The call-site apply(optimistic, …) stays illegal because Mutation & { kind: 'optimistic' } is not assignable to Mutation & { kind: 'idle' }, which is the compile-time guard the lesson installs.

The point of the pattern is to recognize when a feature calls for one. Pair each SaaS feature description with the state machine that fits it. The verbs and stages in each description are the cues.

Pair each SaaS feature with the state machine that fits it. Look for the lifecycle in each description — the verbs and stages are the cues. Click an item on the left, then its match on the right. Press Check when done.

An invoice payment Stripe is retrying after a failed charge.
Subscription/billing machine with past_due carrying a nextRetryAt timestamp.
A file the user is dragging into the upload zone.
Upload machine — idle, uploading with AbortController, done with URL, error.
A draft article the author keeps editing until they hit Publish.
Draft-to-published machine — draft, review, published, archived.
A comment the user typed and submitted; the UI shows it immediately while the server persists.
Optimistic mutation — idle, optimistic with original held for rollback, confirmed, failed.
A user that just signed up and needs to verify their email before any other action.
Onboarding machine — unverified, verified-incomplete-profile, verified-complete.