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; 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

  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. Write 10 tests, one per cell in the table.
  4. Add a guard: login succeeds only if rate limit not exceeded.

Read This Only If Stuck