Definition
A state machine keeps a value in exactly one of a few named phases and, for each phase, lists the events it accepts and the phase each one leads to; any other event is refused and changes nothing.
01Recognize the flags
One result changes what may happen next.
A seed bank keeps one lot in storage and tests whether its seeds still germinate. A test runs in a chamber for days, and a result either releases the lot (a modeled status here, not a real viability claim) or holds it for another decision. A few booleans are a fair start: a testing flag can show progress, and released and held flags can show an outcome.
// One stored lot, three flags.
const lot = { testing: false, released: false, held: false };
function startTest() {
if (lot.released) return; // a released lot is finished
lot.testing = true;
lot.held = false;
}
function abandon() {
lot.testing = false;
}
function report(passed: boolean) {
if (!lot.testing) return; // no test, so no result to record
lot.testing = false;
if (passed) lot.released = true;
else lot.held = true;
} That is fine while there is one test at a time. Then a chamber fails mid-run: the technician
abandons run 1 and starts a retest, and an hour later the first chamber, back online, reports
its pass. report(true) sees lot.testing set, by the retest, and releases the lot on the
abandoned run’s result. When a run can be abandoned and retried, those flags no longer say which
test a result belongs to.
Three flags also make eight combinations for a lot with four phases, and every function has to keep the other four from happening: starting a test clears the held flag by hand, and a lot that is both testing and released is one forgotten line away.
A state machine makes the current phase and incoming event decide the next phase. A guard adds a condition that must hold before the transition, such as “this result carries the active run ID.”
| Input | Here | Why |
|---|---|---|
| Current state | Testing, run 2 | The lot is in a retest. Run 2 is active. |
| Incoming event | Pass from run 1 | The old run was abandoned before this result arrived. |
| Guard + transition | Refuse as stale | The phase permits a result, but the run identity does not match. |
02Name the phases
Draw the paths the lot may take.
The control phases are stored, testing, held and released. Starting a test allocates the next run ID; the lot remembers the last
run it allocated and the run in progress, if any. A pass releases the modeled lot; a result that
needs another decision leaves it held. The state stores both the finite phase and run context.
The context avoids drawing a separate phase for every possible run number.
| Current phase | Event | Next phase | Decision |
|---|---|---|---|
| stored, held | start | testing | Allocate the next run ID; request a test |
| testing | abandon | stored | Clear the active run |
| testing | pass | released | Only if the run ID matches |
| testing | hold | held | Only if the run ID matches |
| Every other pair | Any unlisted event | Unchanged | Refused; no effect requested |
The transition function calculates a resulting state, a reason, and any requested effect. It does not run the test. One owner commits the resulting state, then an adapter may start the requested test. A refusal preserves the input state and requests no effect. This is the whole program the lab runs: the table, the transition, the owner, and the story’s five events.
type Phase = 'stored' | 'testing' | 'held' | 'released';
type LotEvent =
| { type: 'start' }
| { type: 'abandon' }
| { type: 'pass' | 'hold'; runId: number };
type Lot = { phase: Phase; lastRun: number; activeRun: number | null };
type Decision = { lot: Lot; reason: string; effect: string };
// Which events each phase allows, and the phase each one leads to.
const transitions: Record<Phase, Partial<Record<LotEvent['type'], Phase>>> = {
stored: { start: 'testing' },
testing: { abandon: 'stored', pass: 'released', hold: 'held' },
held: { start: 'testing' },
released: {},
};
const storedLot = (): Lot => ({ phase: 'stored', lastRun: 0, activeRun: null });
const accept = (lot: Lot, effect = 'none'): Decision =>
({ lot, reason: 'accepted', effect });
const refuse = (lot: Lot, reason: string): Decision =>
({ lot, reason, effect: 'none' });
// Decides and returns; it does no work. Its caller commits the lot.
function transition(lot: Lot, event: LotEvent): Decision {
const next = transitions[lot.phase][event.type];
if (next === undefined) return refuse(lot, 'not-allowed');
if (event.type === 'start') {
const run = lot.lastRun + 1;
return accept({ phase: next, lastRun: run, activeRun: run }, 'start-test');
}
if (event.type === 'abandon') {
return accept({ ...lot, phase: next, activeRun: null });
}
// A result counts only if it comes from the active run.
if (event.runId !== lot.activeRun) return refuse(lot, 'stale-run');
return accept({ ...lot, phase: next, activeRun: null });
}
// The owner: one place holds the lot and commits each decision.
let lot = storedLot();
function dispatch(event: LotEvent): string {
const decision = transition(lot, event);
lot = decision.lot;
const reason = decision.reason;
console.log(`${event.type}: ${lot.phase}, run ${lot.lastRun}, ${reason}`);
return reason;
}
dispatch({ type: 'start' });
const abandoned = lot.activeRun!;
dispatch({ type: 'abandon' });
dispatch({ type: 'start' });
const current = lot.activeRun!;
const late = dispatch({ type: 'pass', runId: abandoned });
dispatch({ type: 'pass', runId: current });
console.log(`late pass: ${late}; the lot ends ${lot.phase}`); | Part | What it does |
|---|---|
| transitions | Which events each phase allows, and where each leads. |
| transition | Decides: the next lot, a reason, an effect. Does no work. |
| dispatch, the owner | Holds the one current lot and commits each decision. |
| The test chamber | Not in the program: runs a test, reports back as an event. |
Why keep both a phase and a run ID?Control state and context
The phase answers which kinds of event are possible. The active run ID answers which result
belongs to the work currently represented by that phase. Either rule alone is incomplete: a
matching ID cannot make a result legal from stored, and a legal testing → released transition cannot tell a current result from an abandoned run.
03Predict the late pass
The old run reports after the retest has begun.
Experiment
One lot, two runs, a late pass
// Line 32, where abandon returns, becomes:
return accept(storedLot()); What a production state machine addsDecoding, a run limit, an owner that acts
The same transition, ready for a service. A message from the chamber is decoded and checked
before it becomes an event: an unknown event or a run ID that is not a whole number from 1 up
is an error, not a refusal. A start at the largest run ID is refused as run-limit, because past Number.MAX_SAFE_INTEGER, lastRun + 1 rounds and two runs could share a number. And an owner holds the lot, commits each decision, and only then asks
the chamber to start the run it committed. JavaScript runs one dispatch at a time, so committing before the chamber is called is what
lets a chamber that answers at once report against the run it was given.
export type Phase = 'stored' | 'testing' | 'held' | 'released';
export type LotEvent =
| Readonly<{ type: 'start' }>
| Readonly<{ type: 'abandon' }>
| Readonly<{ type: 'pass' | 'hold'; runId: number }>;
export type Lot = Readonly<{ phase: Phase; lastRun: number; activeRun: number | null }>;
export type Reason = 'accepted' | 'not-allowed' | 'stale-run' | 'run-limit';
export type Effect = 'none' | 'start-test';
export type Decision = Readonly<{ lot: Lot; reason: Reason; effect: Effect }>;
const transitions: Readonly<Record<Phase, Partial<Record<LotEvent['type'], Phase>>>> = {
stored: { start: 'testing' },
testing: { abandon: 'stored', pass: 'released', hold: 'held' },
held: { start: 'testing' },
released: {}
};
export const storedLot = (): Lot => ({ phase: 'stored', lastRun: 0, activeRun: null });
export function transition(lot: Lot, event: LotEvent): Decision {
const refuse = (reason: Reason): Decision => ({ lot, reason, effect: 'none' });
const accept = (next: Lot, effect: Effect = 'none'): Decision =>
({ lot: next, reason: 'accepted', effect });
const next = transitions[lot.phase][event.type];
if (next === undefined) return refuse('not-allowed');
if (event.type === 'start') {
// Past this, lastRun + 1 rounds, and a new run could get an old run's ID.
if (lot.lastRun >= Number.MAX_SAFE_INTEGER) return refuse('run-limit');
const run = lot.lastRun + 1;
return accept({ phase: next, lastRun: run, activeRun: run }, 'start-test');
}
if (event.type === 'abandon') return accept({ ...lot, phase: next, activeRun: null });
if (event.runId !== lot.activeRun) return refuse('stale-run');
return accept({ ...lot, phase: next, activeRun: null });
}
/**
* A message from the test chamber, decoded from JSON, is not an event yet:
* check its shape and throw on anything the machine does not know.
*/
export function decodeEvent(message: unknown): LotEvent {
if (typeof message !== 'object' || message === null || Array.isArray(message))
throw new TypeError('event is not an object');
const { type, runId } = message as { type?: unknown; runId?: unknown };
if (type === 'start' || type === 'abandon') return { type };
if (type !== 'pass' && type !== 'hold') throw new TypeError(`unknown event: ${String(type)}`);
if (typeof runId !== 'number' || !Number.isSafeInteger(runId) || runId < 1)
throw new TypeError('run id must be a positive safe integer');
return { type, runId };
}
/** Starts a real test. It reports back later, as a pass or hold event. */
export interface TestChamber {
start(runId: number): void;
}
/** The one place that holds the lot: commit the decision, then do what it asked. */
export class LotOwner {
#lot: Lot = storedLot();
readonly #chamber: TestChamber;
constructor(chamber: TestChamber) {
this.#chamber = chamber;
}
get lot(): Lot {
return this.#lot;
}
dispatch(event: LotEvent): Decision {
const decision = transition(this.#lot, event);
this.#lot = decision.lot;
// Committed first, so a chamber that answers at once sees the new run.
if (decision.effect === 'start-test' && decision.lot.activeRun !== null)
this.#chamber.start(decision.lot.activeRun);
return decision;
}
} Reading the TypeScriptTagged unions, Partial and spreads
LotEvent is a tagged union: only pass and hold carry a runId, so TypeScript lets transition read event.runId only after the start and abandon branches
have returned. Partial<Record<…>> lets each phase list only the
events it allows; a missing entry is undefined, which transition refuses.
{ ...lot, phase: next, activeRun: null } copies the lot with two fields
replaced, so the lot passed in is never changed and a refusal can hand it back as it was. lot.activeRun! tells the type checker the value is not null there,
just after an accepted start; the production owner checks instead.
04Meet it in your language
You already have runs that report late.
The browser’s WebSocket is a state machine you already use: readyState moves from CONNECTING to OPEN, CLOSING and CLOSED, and send() while connecting throws an InvalidStateError. The late result is in the code around it. A chat client that
switches rooms closes the old socket and opens a new one, but the old socket’s close event arrives later, after the closing handshake, and the before version
writes closed over the new connection’s status. Each socket is a run, and the
socket object is its ID.
// A chat client that reconnects when the reader switches rooms.
type Connection = 'connecting' | 'open' | 'closed';
// Before: every socket's events write to the one connection status.
let socketBefore: WebSocket | null = null;
let connectionBefore: Connection = 'closed';
export function connectBefore(url: string) {
socketBefore?.close();
socketBefore = new WebSocket(url);
connectionBefore = 'connecting';
socketBefore.onopen = () => { connectionBefore = 'open'; };
socketBefore.onclose = () => { connectionBefore = 'closed'; };
}
// After: each socket is one run, and only the current one may report.
let socket: WebSocket | null = null;
let connection: Connection = 'closed';
export function connect(url: string) {
socket?.close();
const mine = new WebSocket(url);
socket = mine;
connection = 'connecting';
mine.onopen = () => { if (socket === mine) connection = 'open'; };
mine.onclose = () => { if (socket === mine) connection = 'closed'; };
}
export const shown = () => [connectionBefore, connection]; Compare with the socket you hold, as the lab’s lot compares run IDs. A reconnect timer started
in onclose needs the same check, or every room switch opens a third socket.
05Decide when it helps
Keep a boolean until phases have different rules.
For a single request with one loading flag and one success path, a boolean may be clearer. A state machine earns its place when events have different legal meanings by phase, when terminal actions must stay terminal, or when callers need one shared transition policy.
| The requirement | Flags | Phases and a table |
|---|---|---|
| A test can pause for a human review before release | Another boolean, and “reviewing and released” becomes possible. | A review phase and the rows into and out of it; nothing else changes. |
| A result arrives from an abandoned run | It looks the same as the current run’s. | The run ID guard refuses it. |
| A form has one Saving… spinner and one success path | One boolean says it. | A table for two phases is ceremony. |
| A lot is tested in two chambers at once | Two runs, one testing flag. | One active run cannot say it: keep a set of runs, or a machine per test. |
The cost is another model to maintain: update the transition table, call site, visible labels, and tests together. It does not serialize concurrent writers or make an external test and a stored state change atomic.
The pattern leaves some decisions to you. A table is data you can print and test; a switch can compute an effect from the event. Someone must serialize the decisions:
one owner per lot, whether a single goroutine, a lock, or a database row with a version column.
A refusal is a normal answer here, while a message the machine cannot decode is an error. And an
effect that fails, a chamber that will not start, should come back as an event of its own, or
the lot sits in testing forever.
Its neighbours: Strategy chooses one of several interchangeable algorithms for a task; a phase may select a policy, but choosing an algorithm is not itself a state transition. Command turns each request into an object, which a machine can log or undo. Observer tells listeners when the phase changes.
06Make the call
Which phase, which run, who acts?
Before you open the answer below, say it in your own words: what does the phase decide, what does the run ID decide, why must a run ID never be handed out twice, and who acts on a decision?
One way to say itPhase, run, owner
The phase decides which kinds of event may happen now, so one table replaces a check in every handler and impossible combinations cannot be written. The run ID decides which result belongs to the work the phase stands for: a late or stale result is refused. It works only while no two runs share a number, so the counter survives abandon and every other transition; an ID that comes back lets an old message match a new run, the same shape as the ABA problem. The transition only decides; one owner commits the decision and then performs its effect, so a preview, a check or a retry can ask without acting. A refusal is an answer, not an error. And when there is one flag and one path, keep the boolean: a machine earns its table when phases give the same event different meanings.
Practice in the lab
One step from the seed lot: a public charging station’s connector, whose charges are sessions with their own numbers, as the lot’s tests are runs. Events arrive from the cable, the driver’s app, the charger and an operator, and the charger’s messages can arrive late. Each level starts from the previous level’s finished code, so you can begin at any of them.
- STATE-MACHINE-101 (easy) The relay closed on a faulted connector
- STATE-MACHINE-102 (medium) A late reading billed the next charge Pro
- STATE-MACHINE-103 (hard) Review: looking at the Start button authorizes the card Pro