The state monad and parameterised state
The previous lectures built monads for failure
(option,
result) and non-determinism (the
list monad). Those are two
instances of a much larger pattern. A monad is a reusable way to
simulate an effect in otherwise pure code: fix the carrier type
'a t, and the one return / bind / let* interface starts
behaving like that effect. State, concurrency, probabilistic choice,
logging, even reversible computation can each be packaged as a
monad. This lecture builds the canonical one, the state monad,
which threads a piece of ambient state through a chain of steps:
each step receives the current state and returns a new one, with no
mutation. We write it once as a functor
parameterised by the state type, behind an interface STATE_MONAD
that extends the MONAD of the
previous lecture with two
operations, get and set. At the end we let the state's type
itself change from step to step, the parameterised state monad,
which is the first bridge to GADTs.
Threading state, not mutating it
A stateful computation producing an 'a is a function from the
current state to a pair of (value, new state): state -> 'a * state. This is the carrier 'a t of the state monad, and it plays
exactly the role that 'a option played in the option monad and
'a list in the list monad: each monad is "a value of shape 'a t
plus return and bind", and here the shape happens to be a
function rather than a data structure. There is no mutable cell
anywhere; each step takes the state in and hands the next state out,
and bind is what lines the output of one step up with the input of
the next.
We saw in the previous lecture that
every monad implements one MONAD interface. The state monad needs
two operations beyond return and bind, so its interface
extends MONAD:
The state is always some fixed type, but which type depends on the
program: an int counter here, a record elsewhere. That is exactly
what functors are for. We write the
implementation once, parameterised by a module that supplies the
state type:
The result signature STATE_MONAD with type state = S.t keeps
state visible (callers know it is int, say) while leaving 'a t
abstract, just as Map.Make exposed key but hid the tree. That
abstraction is the point: outside the functor the representation
state -> 'a * state is invisible, and the only handles on the
state are get and set.
A worked example: gensym
A gensym hands out a fresh symbol each time it is called: x_1,
x_2, x_3. The state parks "the next number to use". We
instantiate State at int, open the result so get, set,
return, and run are in scope, and bind let* to bind:
State starts at 1, ends at 4 (three calls used 1, 2, 3; 4 is the
next available). The key thing: the user-facing code never mentions
the counter, and it could not, since 'a t is abstract. No ref,
no incr; the state is implicit in the let* plumbing, threaded by
bind. And gensym is itself the domain helper that hides get
and set: callers write gensym "x", never get or set
directly. Wrapping the raw state operations in a meaningfully named
function (gensym, next_token, read_byte) is the usual way to
use the monad.
State monad versus ref
The ref version of gensym is shorter:
The "tests cannot reset" point is worth seeing concretely. Take two
tests, one that increments once and one that increments twice, each
meant to start from 0. In the state monad each test is just a
value we run from a fresh 0, so they cannot interfere:
The shared-ref version has no such reset. Both tests read and
write the same global cell (the hazard gensym_ref already has),
so the second test's result depends on whether the first one ran:
Pick by scale: a one-off counter, use ref; a whole module of
state-threading computations, the monad pays off (state in the
type, local reasoning per step); parallel code, the monad is safer
but more painful. Ask whether you want the state to be a value
(visible in types, threaded by the monad) or a side effect
(invisible, mutated in place). Both are legitimate.
The same State functor, instantiated at a different state type,
becomes a different tool: a PRNG (state is the seed), a parser
(state is the unread input), a type checker (state is the
environment plus a fresh-variable counter). The interface, return,
bind, let*, get, set, run, never changes; only state
varies, which is exactly why it is a functor.
When the state's type should change
The State functor fixes one state type for the whole
computation: IntState threads an int from start to finish, and
'a t is int -> 'a * int throughout. But sometimes the state's
type should change between steps. Imagine a small stack machine:
push 5 turns a stack of shape 's into one of shape int * 's;
add turns int * (int * 's) into int * 's. The state's type
is the running shape of the stack. The fix is to give the carrier
two state indices, a 'pre and a 'post, and bundle the result
behind its own interface, just as we did for STATE_MONAD:
Read bind's indices the way you would a relay race: the first
step carries the state from 'p to 'q, hands the baton to the
continuation, which carries it from 'q to 'r, and the composite
runs from 'p to 'r. The middle type 'q has to match exactly,
and that handover check is what will let the compiler chain
preconditions step by step.
The implementation is a plain module, not a functor: there is no
single state type to parameterise over, since the type changes per
operation. Otherwise it mirrors State exactly, abstract carrier and
all: get and set are again the only state-aware operations.
A well-typed stack machine
Now the payoff use case. We want a stack machine where the type
tracks the shape of the stack, so that add can run only when there
really are two ints on top, and a malformed program is rejected by
the compiler instead of crashing at runtime. We get this directly
from the PState monad: take the state to be the stack itself,
and each instruction becomes a PState.t whose 'pre and 'post
indices record how it reshapes the stack.
A stack is a nested pair with unit at the bottom, so its type
records the whole shape. Each instruction is built from get and
set, never by poking the representation:
push x always succeeds: any stack accepts a value on top. add
is fussier: its type int * (int * 's) demands at least two ints
on top.
One piece of fine print about that 's. push is a function, but
add is a bind application, so the
value restriction leaves its 's only
weakly polymorphic (the toplevel reports '_s). Every program in
this lecture uses add on stacks with the same tail type, so
nothing notices; if one session reused it at two different
stack-tail types, OCaml would complain at the second use. The fix
is the usual one, making it a function: let add () = ..., applied
as add () (the same applies to any instruction defined this way).
Run two pushes and an add and the types line up:
Push a bool instead, and add can no longer apply. The mismatch
is a compile error, not a runtime one:
This is the payoff. "A stack machine that needs two ints on top to add" is a constraint that lives in the type of the operation, and the compiler enforces it before any code runs.
This is not a toy. It is essentially how WebAssembly bytecode is
typed. A Wasm module is validated before it runs by tracking the
types on the operand stack, instruction by instruction: every
instruction is specified with a stack type, and i32.add has
exactly the type our add does, [i32 i32] -> [i32], popping two
i32s and pushing one. The validator walks the function body
keeping the operand-stack type up to date and rejects the module if
any instruction does not find the shape it needs, exactly as the
OCaml compiler rejects bad_prog. Our 'pre and 'post indices
are the operand-stack type before and after a step. The same shape
turns up in session types (a client cannot send before it
connects) and in typed builders.
The stack machine is one step short of a GADT: the state type is a witness for the shape of the stack at this point. The next lecture makes the pattern first-class, with constructors that carry such witnesses inline and pattern matching that refines them.
A quick check
After run program 1 in the gensym example the result is
(("x_1", "x_2", "y_3"), 4). Why does the state end at 4 and not
3?
- The counter is off-by-one due to a bug.
- OCaml indexing is one-based.
- After producing
y_3, gensym set the state to4for the next caller. runadds 1 to the final state.
Why: each call reads the current n, sets the state to n + 1, and produces a name using n. After producing y_3 the state
was set to 4. The final state is the "next available", not the
"last used".
Why does the ill-typed let* () = push true in add fail at compile
time rather than runtime?
- OCaml runs the type checker at runtime for safety.
add's type says the input state must beint * (int * 's). Afterpush truethe input isbool * 's. The compiler refuses to matchboolagainstint.- The bool is allocated dynamically.
addraises an exception immediately.
Why: parameterised state encodes each operation's precondition
in its type. add says "I take a state shaped int * (int * 's)",
so the compiler rejects any preceding chain that does not produce
such a state. No runtime check; the error is caught at compile
time.
Show reference solution
A code quiz on the plain state monad:
Write incr_state : unit state that increments the state by 1 and
produces (). Use get and set. (This is the bare state monad,
before the functor packaging.)
Show reference solution
Reference solution:
let incr_state =
let* n = get in
set (n + 1)
Read the state into n, then set it to n + 1. The value of set
is (), which is what incr_state should produce.
What is next
The next lecture starts the GADT half. The parameterised-state pattern reappears there in rigorous form: GADT constructors carry type witnesses, pattern matching refines them, and the compiler tracks state-like information through expressions naturally.
Reading
- Cornell CS3110, Monads: https://cs3110.github.io/textbook/chapters/ds/monads.html
Sources
This lecture's prose, worked examples, and quizzes are original to
this course. The state-monad and parameterised-state framing draw on
the author's CS3100 monads notebook, used here as a private
structural reference; the surface code, comments, and explanations
are written from scratch. Cornell CS3110 and Real
World OCaml are CC BY-NC-ND-licensed and have not been derivatively
reused. See
LICENSES.md
at the repository root for the full source posture.