This started with a frustrating refactor. I needed one function at the bottom of a Python service to query a database asynchronously. That single change forced every function calling it—and several tests—to adopt an asynchronous interface too.
If OCaml is unfamiliar: it is a statically typed functional programming language used in areas including compilers, program analysis, and finance. OCaml 5 added support for multicore parallelism along with a feature called effect handlers. You do not need to know OCaml syntax to follow the idea.
In plain language, an effect lets a function pause and ask the surrounding program to do something on its behalf: fetch data, yield to a scheduler, read some state, or choose between alternatives. A handler decides what that request means and can then resume the function with a result.
Somewhere in the middle of that I reread Bob Nystrom's 2015 essay, "What Color is Your Function?" If you haven't read it, the core idea:
In languages with async/await, every function secretly has a color — synchronous (blue) or asynchronous (red). Red functions can call blue functions, but blue functions cannot call red functions. And once a function turns red, everything that calls it must turn red too.
This sync/async distinction appears in languages including JavaScript, Python, Rust, C#, and Kotlin. Each language handles the details differently.
OCaml takes a different approach. Its handlers make a side effect something the surrounding code interprets rather than a property fixed in the function's type. This post works through that approach from the beginning.
How Async Propagates
Here's a toy version of what happened to me. You have a perfectly reasonable call stack:
function renderDashboard() { const data = processData(getMetrics()); return formatChart(data); } function processData(raw) { return raw.filter(valid).map(normalize); } function getMetrics() { return db.query("SELECT * FROM metrics"); }
Now someone tells you db.query needs to be async. Here's what happens:
Step by step: db.query now returns a Promise. getMetrics must await that promise, so getMetrics becomes async and now returns a promise of its own. processData must await getMetrics, which makes processData async. Finally, renderDashboard must await processData. Tests and mocks that call these functions may need to follow the same interface.
Practical consequences
Beyond changing several function declarations, the sync/async distinction affects how code can be combined:
[1,2,3].map(async x => await fetch(x))returnsPromise[], not the resolved values, so it needs an additionalPromise.all.- If a library expects a sync comparator
(a, b) => number, you cannot implement it with async code. There's no workaround. I've hit this one in real life and the answer was "restructure everything." - The same change reaches tests. A synchronous mock may need to adopt the asynchronous interface even when it already has the result available.
What OCaml 5 Did Instead
Instead of baking side effects into a function's type, let the function declare what it needs (an effect) and let the caller decide how to provide it (a handler). The function doesn't know or care whether the effect is satisfied synchronously, asynchronously, or not at all.
Here is a small example:
(* 1. Declare an effect — this is a "need" *) type _ Effect.t += Fetch : string -> string Effect.t (* 2. Use it — looks completely normal *) let get_metrics () = Effect.perform (Fetch "https://api.example.com/metrics") let process_data raw = raw |> List.filter valid |> List.map normalize let render_dashboard () = let data = process_data (get_metrics ()) in format_chart data
No async, no await, no Promise. get_metrics looks like a regular function because it is one. The interesting part is the handler — the thing that decides what Fetch actually does:
(* 3. Handle the effect — the caller decides *) let run_with_http f = Effect.Deep.try_with f () { effc = fun (type a) (eff : a Effect.t) -> match eff with | Fetch url -> Some (fun (k : (a, _) Effect.Deep.continuation) -> let body = Http.get url in (* do the actual I/O *) Effect.Deep.continue k body) (* resume with result *) | _ -> None } (* Use it *) let () = run_with_http render_dashboard
The handler catches the Fetch effect, does the actual HTTP call, and resumes the original function with the result. The function that performed the effect never finds out any of this happened — from its point of view, it asked for a string and got a string.
Step Through It: How Perform and Resume Work
Here is the control flow step by step:
perform and continue bounce control between the function and the handler.The thing to notice: the function's control flow stays linear. It hits perform, suspends, the handler runs, and then it resumes exactly where it left off with the value the handler provided. No callbacks, no .then(), no color change.
The mental model that finally stuck for me: it's a resumable exception. perform throws an effect upward, a handler catches it — but unlike an exception, the handler can hand a value back and let the original code keep going as if nothing happened.
The Deeper Insight: Effects Are a Composition Primitive
The same mechanism can express exceptions, state, nondeterminism, logging, and concurrency:
(* State as an effect *) type _ Effect.t += Get : int Effect.t type _ Effect.t += Set : int -> unit Effect.t (* Nondeterminism as an effect *) type _ Effect.t += Choose : bool Effect.t (* A function that uses both — no framework needed *) let my_function () = let x = perform Get in if perform Choose then perform (Set (x + 1)) else perform (Set (x * 2))
A handler for Choose could explore both branches and collect every possible final state — a backtracking search in a few lines. Or flip a coin. Or ask the user. The function does not need to change.