Finite State Machine Modeling in Real Code
What This Concept Is
A finite state machine (FSM) is an explicit model of behavior: a finite set of states, a finite set of events, and a transition function (state, event) -> nextState that may also produce an action. The State pattern is one implementation of an FSM; a transition table is another; a generated parser is a third.
Three common representations in production code:
- Polymorphic states -- State pattern. Each state is a class. Good for rich behavior per state.
- Transition table -- a dictionary
{(state, event): (nextState, action)}. Good when the table is data-driven, auditable, or sizeable. - State enum + switch -- sometimes appropriate for very small FSMs; know when to leave the decision this way.
Extras that mature FSMs include:
- guards:
(state, event, condition) -> nextState - entry/exit actions: things to do on entering or leaving a state
- initial and final states: the bookends
- unrecognized event policy: error, ignore, log
Why It Matters Here
The State pattern tells you how to express states as classes. FSM thinking tells you what to express: which events, which guards, which illegal transitions to prevent. Without FSM discipline, State classes drift into ad-hoc methods and you rediscover the same bugs you refactored out.
FSMs show up everywhere: connection lifecycles, feature flags with multi-step rollouts, checkout workflows, parsers, retry/circuit-breakers.
Concrete Example
A connection FSM using a transition table. Deliberately minimal so the shape is visible.
type State = "disconnected" | "connecting" | "connected" | "closing";
type Event = "connect" | "onOpen" | "disconnect" | "onClose" | "error";
interface Transition { next: State; action?: (ctx: Ctx) => void; }
type Table = Record<State, Partial<Record<Event, Transition>>>;
interface Ctx { log: (s: string) => void; socket?: WebSocket; }
const table: Table = {
disconnected: {
connect: { next: "connecting", action: c => { c.log("open socket"); } },
},
connecting: {
onOpen: { next: "connected", action: c => { c.log("ready"); } },
error: { next: "disconnected", action: c => { c.log("failed; giving up"); } },
},
connected: {
disconnect:{ next: "closing", action: c => { c.log("close requested"); } },
onClose: { next: "disconnected", action: c => { c.log("remote closed"); } },
error: { next: "disconnected", action: c => { c.log("fatal error"); } },
},
closing: {
onClose: { next: "disconnected", action: c => { c.log("closed"); } },
},
};
class Machine {
state: State = "disconnected";
constructor(private ctx: Ctx) {}
send(ev: Event): void {
const t = table[this.state][ev];
if (!t) { this.ctx.log(`ignored ${ev} in ${this.state}`); return; }
t.action?.(this.ctx);
this.ctx.log(`${this.state} -> ${t.next} on ${ev}`);
this.state = t.next;
}
}
const m = new Machine({ log: console.log });
m.send("connect"); m.send("onOpen"); m.send("disconnect"); m.send("onClose");
Missing events are explicitly ignored; no crashes, no weird states. The table is a readable spec.
Common Confusion / Misconception
- Conflating "state machine" with State pattern. They are not synonymous. The pattern is one of several implementations. Pick the representation that matches your data.
- Hiding illegal transitions. A machine that silently accepts every event is a machine that hides bugs. Choose a policy: reject, log, or both.
- Unbounded event sets. If you need any input -> state behavior, you may want a pushdown automaton or a parser, not an FSM.
- Entry/exit actions that escape. If entering a state sends a network request, what happens if you transition away before it completes? Cancellation policy is part of the design.
How To Use It
- Name the states finitely. If you write "…" in the list, you do not have an FSM.
- List the events. Same rule.
- Write the transition table on paper before coding. It will reveal dead states and missing events.
- Decide the policy for unknown
(state, event)pairs. - Choose a representation:
- few states, minimal behavior -> enum + switch
- many states, rich behavior -> State pattern
- data-driven, config-loaded -> transition table
- Log every transition during development; leaving a trace is cheap insurance.
- Test every
(state, event)pair, even the ones that "obviously" do nothing.
Check Yourself
- What three ingredients define an FSM?
- When is a transition table clearer than State classes?
- What is a guard, and where does it go in the transition tuple?
- What is the right default policy for an unknown event, and why document it?
Mini Drill or Application
Model a login flow: states anonymous, awaitingOTP, authenticated, locked; events login, otp, logout, failure.
- Draw the table on paper first.
- Implement with a transition table and an unknown-event policy.
- Write 10 tests, one per cell in the table.
- Add a guard:
loginsucceeds only if rate limit not exceeded.