Effects: Superpowered Control Flow
An exception can only scream "abandon ship!" and burn the call stack on its way out. Today your code learns to raise its hand, ask a question, receive an answer, and carry on as if nothing happened. Welcome to the frontier: effect handlers, OCaml 5's headline superpower.
You will be able to…
- Declare an effect with
type _ Effect.t += …and ask it withEffect.perform - Handle effects with
match … | effect …branches and resume withEffect.Deep.continue - Explain what the continuation
kis — and why effects beat exceptions - Predict what an unhandled effect does at runtime
- Read the older
Effect.Deep.try_withspelling when you meet it in the wild
A question with no answerer
Think about how a function gets information: you pass arguments in at the front door. Think about how it reports disaster: it raises an exception, which tears through the call stack destroying everything on its way to a handler. A one-way trip, no survivors.
An effect is a third thing, stranger and better. A computation can ask a question without knowing who will answer: mid-expression it pauses, the question travels out to the handler, the handler decides, and the computation resumes exactly where it paused, answer in hand. An exception is a fire alarm; an effect is a raised hand.
Declaring one looks like this: type _ Effect.t += Ask : int
Effect.t. Read it as "extend the world's collection of effects with
Ask, a question whose answer is an int". The
+= is real syntax: Effect.t is extensible, like
exn. Asking is Effect.perform Ask; answering is
a brand-new kind of match branch. Here's the whole round
trip:
It prints The quest computed: 42. Now slow down and watch
the machinery, because this is the best trick you'll learn all year:
questperformsAsk. It has no idea where the answer will come from. It pauses mid-let.- The
matchdoes double duty: the first branch handles "questfinished withn"; theeffectbranch handles "questasked" — and receivesk, the paused computation itself. Effect.Deep.continue k 21resumes the pause, and21appears as the value of thatEffect.perform Ask. The story picks up on the very next line ofquest.
And notice it answered twice. With Effect.Deep
handlers, the handler stays on duty for the whole computation: ask twenty
times, get twenty answers, zero extra code.
Break it on purpose
Exceptions have an "uncaught" story, and effects do too. What if nobody answers? Run this and read the report:
The engine stops with an exception called
Effect.Unhandled. Performing is a promise that someone
further out will answer; nobody did, so the question fell off the
edge of the program and became an ordinary exception.
The starter gives you the Ask effect and a
quest that asks twice — keep both. Write
answer_with : int -> (unit -> int) -> int so that
answer_with n f runs f and answers every
Ask it performs with n.
match f ()
with | result -> result | effect Ask, k -> ….Effect.Deep.continue k n —
and remember, the handler stays on duty for the next
Ask automatically.k: a bookmark in a paused story
So what exactly landed in k? The continuation: the
entire rest of the computation — local values, position, everything it was
about to do — frozen into an ordinary OCaml value. Games hand us the
perfect metaphor: perform is a save point. The computation
saves, passes you the save file, and steps aside. continue k
answer loads the save, and play resumes with one addition: the
answer is spliced in where the question stood.
Because k is a value, the handler chooses its fate: resume
immediately (all of today), stash it and resume next frame
(that's lesson 4.2 in one phrase), or never resume it at all. One law to
respect: a continuation is a one-shot ticket — resume it at most
once.
Questions can carry payloads, too. This effect ships a string out with
every ask, and its answer type is unit — sometimes "message
received" is all the answer you need:
Two details worth circling: an effect with a payload matches with
parentheses — effect (Log line), k — and a
unit-answered question is resumed with
Effect.Deep.continue k (): "noted, carry on."
Now notice what mission is not doing: no
printing, no list-building, no policy at all. The function stays clean
and pure-looking — the handler decides what logging means.
Exploit that:
Keep the given Log effect and log helper.
Write collect : (unit -> 'a) -> 'a * string list:
run the function, answer every Log it performs, and
return the function's result paired with all logged lines in the
order they were logged. A ref inside
collect is fine — the effect keeps the scripts
clean, not your handler. (Refresher: let seen = ref [] in
seen := x :: !seen; read it back with !seen.)
f
runs: let lines = ref [] in …, then handle the effect and
remember the payload before continuing.| effect (Log line), k
-> lines := line :: !lines; Effect.Deep.continue k () —
parentheses around the payload pattern, () as the
answer.:: prepends, so your list is newest-first.
Finish with (result, List.rev !lines).Deeper: collect with no ref at all
For the brave, a version with no mutation anywhere: the effect
branch resumes first, receives (result, later_lines) from
the rest of the run, and cons-es its own line on the front. The list
assembles itself, in order, on the way back out:
Here continue k () returns the handler's answer
type — the whole pair — because the value branch | v -> (v,
[]) runs when the script finally finishes. Sit with that for a
minute; it's the key to next lesson.
Same question, different answerers
Here is why game programmers get starry-eyed: the asking code commits
to nothing, so one script can run under many answerers. A dungeon
script performs Roll; the shipped game answers with seeded
randomness, your tests answer with a fixed script of rolls, a replay
answers from yesterday's recorded log. The dungeon never changes. Phase 3
injected modules to swap behaviour; effects inject
answers — at any depth of the call stack, no parameter threaded
through every function in between.
Keep the given Roll effect and roll
helper. Write with_loaded_dice : (unit -> int) ->
int: run the function and answer every Roll with a
suspiciously convenient 6.
answer_with — only
the effect's name and the answer change.| effect Roll, k -> Effect.Deep.continue k
6 answers this roll and stays armed for the rest.The elder spelling: try_with
The match … | effect syntax is young — it landed in OCaml
5.3. Effects themselves shipped with OCaml 5.0, so the wild is full of the
original spelling: Effect.Deep.try_with, which takes the
function, its argument, and a record holding an effect dispatcher. Same
machine, more ceremony:
Read effc as: "for any effect that comes up — if it's
Ask, here's my answer recipe (Some …); anything
else is not mine (None)", and unclaimed questions keep
travelling outward to the next handler. The (type a)
incantation lets one dispatcher serve effects with different answer
types. You won't write this style in Camel Quest — but you can
now read it, and you know what your tidy match desugars
to.
Checkpoint quiz
Loot collected
- An effect is a question asked without knowing who answers:
declare with
type _ Effect.t += Name : answer Effect.t, ask withEffect.perform. - Handlers are match branches:
| effect Ask, k -> Effect.Deep.continue k 42— and deep handlers stay on duty for the whole computation. kis the paused rest of the story — a first-class, one-shot value. Resume it now, later, or never.- No handler ⇒
Effect.Unhandledat runtime; the types won't save you here. Effect.Deep.try_withis the elder spelling of the same machine — read-only knowledge, happily.
Next: we stop resuming immediately. Stash k in your
pocket, deal each script one slice per frame, and direct a whole cast of
characters — coroutines.