← back to writing · February 2026 · 14 min read

The End of Colored Functions: What OCaml 5 Gets Right About Effects

Every mainstream language chose the same bad abstraction for side effects. OCaml 5 chose a different one — and it changes everything about how you think about program composition.

In 2015, Bob Nystrom wrote an essay called "What Color is Your Function?" that quietly diagnosed a disease that had already infected every mainstream language. The essay is short, funny, and devastating. If you haven't read it, the core idea is this:

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.

That essay is now eleven years old. And here's what's wild: almost nothing has changed. JavaScript, Python, Rust, C#, Dart, Kotlin — they all still have colored functions. They all still force you to choose sync or async at function definition time, and that choice propagates virally through your entire codebase.

OCaml 5, released in late 2022, took a completely different path. It introduced algebraic effect handlers — a mechanism that eliminates function coloring entirely. Not by removing async. Not by hiding it behind threads. By making side effects a value you can intercept and reinterpret, rather than a property baked into the function's type.

This matters. And almost nobody outside the PL research community is talking about it.

The Infection Pattern

Let me show you the problem concretely. Say you have a perfectly reasonable call stack:

JavaScript
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:

⚡ Interactive: The Color Infection
Click "Make db.query async" and watch the infection spread upward through the call stack.
renderDashboard()
↓ calls
processData()
↓ calls
getMetrics()
↓ calls
db.query()
sync (blue)
async (red)

One change at the bottom of the stack forced every function above it to change its signature. getMetrics now returns a Promise. So processData needs await, which means it returns a Promise. So renderDashboard needs await too.

This isn't a JavaScript problem. It's a structural problem with how async/await works in every language that adopted it. The function's "color" — whether it's sync or async — is part of its interface, not its implementation. And interfaces propagate.

Why this actually hurts

It's not just annoying refactoring. Colored functions break composition in deep ways:

What OCaml 5 Did Instead

OCaml 5 introduced effect handlers — based on decades of PL research on algebraic effects (Plotkin & Pretnar, 2009). The core insight is deceptively simple:

The Key Idea

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.

Think of it like this. In async/await, a function that does I/O is permanently branded: "I am async." In OCaml 5, a function that does I/O just says: "I need some data" — and someone else decides how to get it.

Here's what the syntax looks like:

OCaml 5
(* 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

Notice anything? No async. No await. No Promise. get_metrics looks like a regular function. render_dashboard looks like a regular function. That's because they are regular functions.

The magic is in the handler — the thing that decides what Fetch actually does:

OCaml 5 — The Handler
(* 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 doesn't know any of this happened — it just got back a string.

Step Through It: How Perform and Resume Work

This is where most explanations lose people. So let's trace it step by step.

⚡ Interactive: Effect Handler Simulator
Step through how perform and continue bounce control between the function and the handler.
Code
let render_dashboard () =
let raw = perform (Fetch url) in
let data = process raw in
format data
(* handler *)
| Fetch url → continue k (http_get url)
Execution Log

The critical thing to notice: the function's control flow is linear. It hits perform, gets suspended, the handler runs, and then the function resumes exactly where it left off with the value the handler provided. No callbacks. No .then(). No color change.

This is essentially a resumable exception. perform throws an effect upward. The handler catches it, does some work, and then — unlike exceptions — gives a value back and lets the original code continue.

The Superpower: Swappable Handlers

Here's where it gets genuinely powerful — and where my original take comes in.

Because the handler is separate from the function, you can swap handlers without touching a single line of your business logic. Same function, different interpretation:

Same function, three handlers
(* Production: real HTTP *)
run_with_http render_dashboard

(* Testing: deterministic mock *)
run_with_mock ["url1", "fake_data"] render_dashboard

(* Debugging: log every fetch, then do it *)
run_with_logging render_dashboard

(* Batching: collect all fetches, do them in parallel *)
run_with_batch render_dashboard

That last one is wild. A batching handler can intercept every Fetch effect, collect them all, fire them in parallel, and resume each function with its result. The function code doesn't change. It doesn't even know it's being batched. You get automatic request deduplication and parallelism for free.

This is what dependency injection should have been. Not a framework. Not annotations. Not a DI container. Just: "I need X" / "Here's how X works."

The Deeper Insight: Effects Are a Composition Primitive

Most discussions of effect handlers stop at "it's better async." I think that misses the real point.

Effects are a universal composition primitive. They're not just for async — they can express exceptions, state, nondeterminism, logging, and concurrency. All with the same mechanism.

Effects beyond async
(* 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 all possible final states. That's a backtracking search engine in three lines. Or it could flip a coin. Or ask the user. The function doesn't care.

Here's the comparison that crystallizes it:

Property Async/Await Effect Handlers
Function coloring Yes — sync vs async No — all functions are the same
Works with higher-order functions map, filter etc. break Transparent — just works
Testability Tests must be async too Swap in a sync mock handler
Batching / parallelism Manual Promise.all Handler can batch automatically
Beyond I/O Only for async State, nondeterminism, logging, etc.
Composability Two parallel ecosystems Handlers compose and nest

Why This Should Matter to You (Even If You'll Never Write OCaml)

I'm not writing this to convert you to OCaml. I'm writing it because effect handlers represent a different way of thinking about program structure, and that way of thinking is leaking into other languages whether they adopt the syntax or not.

React's hooks? They're effects in disguise. useState, useEffect, useContext — these are all "perform an effect, let the framework handle it." React is the handler. Your component is the function. Dan Abramov has written about this explicitly.

Python's context managers? Partial effects. with open(file) as f is really "perform a resource acquisition effect, clean up when done."

Dependency injection frameworks? Trying to solve the same problem effects solve natively — "I need X, someone else provides X" — but with runtime reflection, annotations, and configuration files instead of a language primitive.

The pattern is everywhere. We're building ad-hoc, half-broken versions of effect handlers in every language because the underlying abstraction is so obviously right. OCaml just gave it a proper name and a sound implementation.

The Honest Limitations

I should be fair. Effect handlers aren't all roses:

That last point is important. OCaml took a pragmatic approach: get effect handlers into the language now, even without full type-level effect tracking. The bet is that practical benefits outweigh theoretical purity. It's a very OCaml thing to do.

Where This Is Going

Effect handlers are on a slow collision course with mainstream languages. Rust is debating how to make async less viral. WebAssembly is getting stack switching, which enables effect-handler-like patterns. Even Java's Project Loom (virtual threads) is an admission that colored functions were a mistake — it solves the problem by making everything "async" under the hood so you never have to write the word.

OCaml just got there first, with the most general solution. It's the difference between patching symptoms and fixing the root cause.


The next time you're writing async in front of a function for the fourteenth time in a single refactor, just because one leaf function at the bottom of the call stack now talks to a database — remember that there's a language where that doesn't happen. Where a function that says "I need data" never has to know how the data arrives. Where the caller, not the callee, decides the execution strategy.

That's what OCaml 5 got right. Not better async. No async at all.