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…

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:

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.

Same shape as the sample's handler: match f () with | result -> result | effect Ask, k -> ….
Answer with 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.)

You need somewhere to stash lines while f runs: let lines = ref [] in …, then handle the effect and remember the payload before continuing.
The handler branch is | 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.

Identical shape to 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

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.