Skip to main content

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:

  1. Polymorphic states -- State pattern. Each state is a class. Good for rich behavior per state.
  2. Transition table -- a dictionary {(state, event): (nextState, action)}. Good when the table is data-driven, auditable, or sizeable.
  3. 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. Actions and logging may still throw or reenter before state assignment; this sketch is not atomic or reentrancy-safe.

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.
  • Data versus control state. Events can carry arbitrary values classified into finitely many event kinds. A stack is needed for unbounded nesting, not merely because input values are numerous.
  • 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​

  1. Name the states finitely. If you write "…" in the list, you do not have an FSM.
  2. List the events. Same rule.
  3. Write the transition table on paper before coding. It will reveal dead states and missing events.
  4. Decide the policy for unknown (state, event) pairs.
  5. Choose a representation:
    • few states, minimal behavior -> enum + switch
    • many states, rich behavior -> State pattern
    • data-driven, config-loaded -> transition table
  6. Log every transition during development; leaving a trace is cheap insurance.
  7. Test every (state, event) pair, even the ones that "obviously" do nothing.

Check Yourself​

  1. What three ingredients define an FSM?
  2. When is a transition table clearer than State classes?
  3. What is a guard, and where does it go in the transition tuple?
  4. 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.

  1. Draw the table on paper first.
  2. Implement with a transition table and an unknown-event policy.
  3. Check all 16 state/event cells, including rejection/ignore cells, then both outcomes of each guard.
  4. Add a guard: login succeeds only if rate limit not exceeded.

Read This Only If Stuck​

Contract and transfer​

Treat action failure and state transition order as part of the machine. If an action sends a message before updating state and then raises, local state may be old while the outside world has changed. Updating state first moves the failure window; it does not erase it. L04 deliberately uses pure transitions and local accepted records so those claims remain small and testable.

Cell coverage checks each transition but can miss history bugs: a stale callback from connection attempt 1 can arrive during attempt 2. Add an attempt identifier and reject stale results when the contract requires it. A transition table with guards must define precedence if two guards match and behavior if none do.

Transfer: draft -> review -> draft -> review. A delayed approval for the first review arrives. A four-state label alone cannot distinguish review generations. Add a version to the state and expected version to the command, then specify the new rejection. This is a model extension, not merely another class.