State Machines
- A finite state machine models a system as a fixed set of states and a function that maps (state, event) pairs to the next state.
- Representing states and events as custom types lets the compiler check that every case is handled.
- The transition function returns
Result(State, String)to reject invalid event sequences at runtime. - A recursive
runfunction applies a list of events one at a time, stopping on the first error.
Why State Machines?
- Every time a browser sends an HTTP request,
the server processes it through a fixed sequence of steps:
- Read the incoming bytes
- Match the URL to a handler
- Run that handler
- Write a response back
- An experienced developer holds this picture in their head; a finite state machine puts it in the code
- The core idea:
- a fixed set of states the system can be in
- a fixed set of events that can arrive
- a transition function that says "if I am in state S and event E arrives, move to state N"
- Any event that has no entry in the table for the current state is invalid, which is how the machine enforces legal behavior
- State machines appear everywhere: TCP connection handling, UI workflow steps, and characters in video games
The States and Events
- An HTTP request lifecycle has six states:
pub type State {
Idle
Reading
Routing
Handling
Sending
Done
}
pub type Event {
Connect
Data(String)
Match(String)
NoMatch
Response(Int)
Sent
}
Idlewaits for a new connectionReadingreceives incoming bytes from the networkRoutingmatches the request path to a registered handlerHandlingruns the application code for that handlerSendingwrites the response bytes back to the clientDonesignals that the response was delivered- The events that drive those transitions:
Connect: a new TCP connection arrivesData(String): the request line (method and path) has been readMatch(String): a route handler was found for the pathNoMatch: no handler matched (the server will return 404)Response(Int): the handler finished with an HTTP status codeSent: all response bytes were flushed to the network
- Carrying data inside event variants (
Data,Match,Response) lets callers attach context even if the transition function ignores it for the purpose of deciding which state comes next
The Transition Function
pub fn step(state: State, event: Event) -> Result(State, String) {
case state, event {
Idle, Connect -> Ok(Reading)
Reading, Data(_) -> Ok(Routing)
Routing, Match(_) -> Ok(Handling)
Routing, NoMatch -> Ok(Sending)
Handling, Response(_) -> Ok(Sending)
Sending, Sent -> Ok(Done)
_, _ ->
Error(
"cannot handle "
<> string.inspect(event)
<> " in state "
<> string.inspect(state),
)
}
}
case state, eventmatches on both values simultaneously without wrapping them in a tuple- Each valid pair maps to
Ok(next_state); everything else maps to anErrorcarrying a message string.inspectrenders the state and event without a custom formatter- Only six transitions are valid; the
_, _arm rejects all other combinations - This is the complete rule table of the machine, written as a single pattern match
Running a Sequence of Events
pub fn run(state: State, events: List(Event)) -> Result(State, String) {
case events {
[] -> Ok(state)
[event, ..rest] ->
case step(state, event) {
Error(msg) -> Error(msg)
Ok(next) -> run(next, rest)
}
}
}
runapplies a list of events to a starting state, returning the final state or the first error- Base case: no events remain, so the current state is the result
- Recursive case: apply
stepto the first event; if it fails, return the error immediately; otherwise recurse with the new state and the remaining events - Once a bad event arrives, no further events are processed
The Happy Path and the 404 Path
let happy_path = [
Connect,
Data("GET /users"),
Match("users_handler"),
Response(200),
Sent,
]
io.println("Happy path: " <> string.inspect(run(Idle, happy_path)))
let not_found = [Connect, Data("GET /nope"), NoMatch, Sent]
io.println("Not found: " <> string.inspect(run(Idle, not_found)))
let bad = [Connect, Sent]
io.println("Bad event: " <> string.inspect(run(Idle, bad)))
- The happy path moves through all six states:
Idle → Reading → Routing → Handling → Sending → Done - The 404 path skips
Handlingentirely:NoMatchtransitions directly fromRoutingtoSending, because there is no handler to run - Both paths end at
Ok(Done) - The bad path tries
Sentwhile still inReading, which is nonsense, and gets anErrorexplaining exactly what went wrong
Testing
pub fn idle_connect_test() {
step(Idle, Connect)
|> should.equal(Ok(Reading))
}
pub fn reading_data_test() {
step(Reading, Data("GET /users"))
|> should.equal(Ok(Routing))
}
pub fn routing_match_test() {
step(Routing, Match("users_handler"))
|> should.equal(Ok(Handling))
}
pub fn routing_not_found_test() {
step(Routing, NoMatch)
|> should.equal(Ok(Sending))
}
pub fn handling_response_test() {
step(Handling, Response(200))
|> should.equal(Ok(Sending))
}
pub fn sending_sent_test() {
step(Sending, Sent)
|> should.equal(Ok(Done))
}
pub fn invalid_transition_test() {
step(Idle, Sent)
|> should.be_error()
}
pub fn happy_path_test() {
let events = [
Connect,
Data("GET /users"),
Match("users_handler"),
Response(200),
Sent,
]
run(Idle, events)
|> should.equal(Ok(Done))
}
pub fn not_found_path_test() {
let events = [Connect, Data("GET /nope"), NoMatch, Sent]
run(Idle, events)
|> should.equal(Ok(Done))
}
pub fn stops_on_error_test() {
let events = [Connect, Sent]
run(Idle, events)
|> should.be_error()
}
- Each single-step test calls
stepdirectly with one state and one event should.equal(Ok(Reading))verifies the exact next stateshould.be_error()checks that an invalid transition is rejected without caring about the error message text- The
runtests verify complete event sequences, which is closer to how the machine is actually used in production
Check Understanding
Why does step return Result(State, String) rather than just State?
A real server can receive malformed or out-of-order events:
a client might drop the connection mid-request,
or a bug might deliver the wrong event at the wrong time.
Returning Result forces the caller to decide what to do when an invalid event arrives.
If step returned a bare State, the only options would be to panic,
silently stay in the current state, or add a sentinel state like Crashed.
Result keeps the transition function pure
and lets the caller handle the failure in whatever way makes sense for its context.
What happens to events after an invalid transition in the list passed to run?
They are never processed.
run stops immediately and returns the Error from step.
This is the short-circuit behavior in the recursive case:
when step returns Error(msg), the function returns Error(msg)
without recursing further.
The final valid state reached just before the error is also lost;
a caller that needs to recover it would have to change run
to return both the last valid state and the error message.
Exercises
Trace transitions (10 minutes)
Write trace(initial: State, events: List(Event)) -> List(String)
that returns one human-readable string per successful transition,
formatted as "Idle --Connect--> Reading".
Stop without adding an entry when an invalid event is encountered.
Reset event (15 minutes)
Add Reset to Event.
step should accept Reset from any state and return Ok(Idle).
Write at least three tests:
one that resets from Handling,
one from Sending,
and one confirming that run handles a reset in the middle of a longer event list
and that the events following the reset proceed correctly from Idle.
Valid events (15 minutes)
Write valid_events(state: State) -> List(Event) that returns every event
that produces Ok from the given state.
Do not call step inside the function; enumerate the transitions directly.
Write one test per state confirming that every event in the returned list
really does produce Ok from that state,
and that valid_events(Idle) does not include Sent or Response(0).
HTTP keepalive (20 minutes)
In HTTP/1.1, a connection can serve multiple requests without reconnecting.
Modify step so that Done + Data(_) -> Ok(Routing),
skipping Reading since the TCP connection is already open.
Write two tests:
one showing a two-request keepalive sequence that ends at Done after both requests,
and one confirming that Done + Connect still returns Error.