Algebraic Effects: From Callbacks and Promises to Effect Handlers

Published by

on

Algebraic Effects: From Callbacks and Promises to Effect Handlers

Decoupling computation from the interpretation of its effects


Introduction

There is a moment in programming that almost every developer recognizes. A piece of code begins as a simple sequence of ideas: take a value, transform it, return a result. At that stage, everything feels local, direct, and easy to follow. The logic is visible in the structure of the code itself, and reading it often feels close to reading the problem it is meant to solve.

That clarity rarely lasts unchanged for long. Sooner or later, the computation needs something that does not belong to pure local reasoning anymore. It may need to fetch data from a server, handle a failure, wait for an asynchronous result, write a log entry, retry an operation, read configuration, or interact with some external system. At that point, the code is no longer shaped only by the logic of the problem. It also becomes shaped by the mechanics of execution.

This is where things begin to shift. A callback moves the rest of the computation into another function. A promise pushes it into .then(...) and .catch(...)async/await restores a more direct style, but only because the runtime silently takes responsibility for suspending and resuming the computation behind the scenes. Exceptions interrupt the normal path and redirect control else where. Other abstractions place the computation inside containers or contexts that the surrounding program must now respect.

None of these mechanisms are mistakes. They are useful, necessary, and deeply embedded in the way modern software is written. But they all reveal the same underlying tension: as soon as a program needs something from outside itself, control flow becomes part of the problem. The question is no longer only what should this code do? It also becomes how should this computation proceed when it depends on time, failure, IO, or some external service?

That is the point where algebraic effects become interesting.

The name can sound more abstract than the idea really is. In practice, the intuition is surprisingly natural. A program reaches a point where it cannot continue on its own, so instead of deciding immediately how the needed action must be carried out, it simply requests that action. Something else, a handler, gives meaning to that request. Once the request has been interpreted, the computation continues.

This is the heart of algebraic effects: the program asks for an operation, a handler decides what that operation means, and the computation resumes with the result.

What makes this idea powerful is that it separates two concerns that are often entangled in ordinary effectful code. On one side, there is the intention of the program: what it wants to do. On the other side, there is the interpretation of that intention: how it is actually executed in a given context. That separation may seem small at first, but it changes the way side effects can be organized. Instead of forcing the structure of the whole program to bend around promises, callbacks, exception paths, or other execution machinery, algebraic effects invite us to imagine code that stays closer to direct reasoning, while the runtime or the surrounding handlers take responsibility for the operational details.

This is why algebraic effects are often seen as an elegant response to an old and recurring difficulty in programming. They do not eliminate side effects, and they do not make complexity disappear. What they offer is a different organization of that complexity, one in which the program can remain focused on expressing its needs while handlers determine how those needs are fulfilled.

That is the path this article will follow. We will begin with mechanisms that already feel familiar, such as callbacks, promises, async/await, exceptions, generators, and React Suspense. From there, we will build the core intuition behind three central ideas: effect, handler, and continuation, before looking at why algebraic effects feel close to many existing mechanisms and yet still introduce something genuinely new.

If the term still feels abstract at this stage, that is completely normal. By the end, the goal is for the idea to feel much simpler than the name first suggests:

the program asks,
something handles,
the computation continues.


The Problem with Traditional Side Effects

Before looking at algebraic effects directly, it helps to understand the tension they are trying to resolve. A function that only computes is usually easy to follow. It receives values, transforms them, and returns a result. The logic stays in one place, the control flow remains visible, and reading the code often feels straightforward because the structure of the program mirrors the structure of the problem.

That clarity begins to fade when the function depends on something external. It may need to fetch a user from an API, write to a file, wait for a timer, log an event, or handle the possibility of failure. At that point, the code is no longer shaped only by local computation. It also becomes shaped by time, IO, failure, and by the runtime mechanisms required to deal with them.

This is where programming starts to feel different.

One of the oldest and most familiar responses to this problem is the callback:

fetchUser((user) => {
console.log(user.name);
});

The code works, and its intention is easy to understand: when the data is ready, call this function. But something important has changed. The rest of the computation is no longer written as the next visible step of the program. It has been moved into another function and handed off to something else. What should happen next is no longer written directly in the main flow. It has become a continuation passed outward.

That is why callbacks often feel like a shift in control. We are not simply writing the next line of logic anymore. We are packaging the future of the computation and giving it to another mechanism. This is manageable in small examples, but when several asynchronous or effectful operations need to be combined, the code starts to nest, fragment, and lose its linear shape. This is what lies behind the familiar phrase “callback hell.” The real problem is not indentation by itself, but that control flow becomes harder to see.

Promises improved this situation significantly:

fetchUser()
.then(user => process(user))
.catch(error => handleError(error));

This is a real improvement. The structure is cleaner, composition is easier, and errors can be handled more regularly. But the underlying pressure is still there. The continuation has not disappeared; it has simply moved into .then(...)and .catch(...). The future of the computation is now expressed through the promise chain.

This matters because the surrounding code must still adapt to the structure introduced by the effect. Instead of writing the next steps directly, we write them inside the promise machinery. In that sense, promises are both powerful and slightly invasive. They solve a difficult problem, but they also leave a visible mark on the shape of the program.

async/await brought another major improvement by restoring a more direct style:

const user = await fetchUser();
process(user);

This reads much more like ordinary code. For many developers, it is the first moment where asynchronous programming starts to feel calm again. But the simplicity here is partly syntactic. Behind the scenes, the runtime is still doing something sophisticated. When execution reaches await, the current computation cannot continue immediately. It must pause, wait for the promise to settle, and later resume. The code after await has not disappeared, but it has been suspended, preserved, and handed to the runtime until the awaited result becomes available.

So async/await improves readability, but it does not remove the deeper issue. It gives us a more comfortable surface over a control-flow mechanism that remains special, still tied to promises, still built into the language, and still managed by the runtime.

Exceptions reveal the same pattern in another form:

try {
riskyOperation();
} catch (error) {
handleError(error);
}

Here the code is not waiting for a value. Instead, it may leave the normal path entirely. A thrown error escapes the ordinary flow of execution and jumps through the call stack until a matching catch is found. Once again, the significance is not only that an error exists, but that the future of the computation is no longer guaranteed to follow the visible line-by-line path of the program.

This is why exceptions belong in the same broader discussion. They remind us that once effects enter the picture, computation is no longer only about producing values. It is also about who controls what happens next.

Seen from this angle, callbacks, promises, async/await, and exceptions are all responses to the same underlying difficulty. A program sometimes needs to do something that is not just local computation. And when that happens, one of two things usually follows: either the computation gets wrapped inside some structure, or the control flow gets redirected, suspended, or fragmented.

That is the pressure algebraic effects are trying to relieve.

Instead of shaping the whole program around a specific mechanism for each kind of effect, they raise a different question: could a program simply express what it needs, while another part of the system decides how that need should be handled?

That is the idea we turn to next.


The Core Idea of Algebraic Effects

If callbacks move the future of the computation into another function, promises place it inside a chain, async/await suspends it through the runtime, and exceptions redirect it through the stack, then the natural question is this: is there a more general way to think about all these situations?

Algebraic effects answer yes.

The central idea is simple, even if the name initially sounds abstract. A program can reach a point where it needs an operation to be carried out, but instead of deciding immediately how that operation should happen, it simply requests it. That request is the effect. The code that gives meaning to that request is the handler. And the part of the program that still remains to run after the effect is resolved is the continuation.

These are the three core notions:

  • effect
  • handler
  • continuation

An effect is not the whole side effect itself. It is the request for an operation. Suppose a program needs to obtain a user:

user = getUser("/user")
print(user.name)

In everyday code, getUser("/user") immediately suggests an HTTP call. But the effect-oriented view asks us to look at it differently. The important point is not yet how the user will be fetched. The important point is only that the program needs a user. From that perspective, we can think of the operation as something like:

perform getUser("/user")

That is the effect: a request made by the program for something it cannot or does not want to define by itself.

A handler is what interprets that request. It says, in effect: “if the program asks for this operation, here is what it should mean in this context.” In pseudocode, we might write:

effect HTTP {
get(url)
}

and then:

handle HTTP.get(url) {
return fetch(url)
}

This already shows the first important separation. The program expresses the need; the handler provides the meaning. The program does not commit to HTTP, or to a mock implementation, or to a cache, or to a database. It simply asks for HTTP.get(url). The handler decides what that request means here.

That alone is already useful, but it is not yet the whole story. The third notion, the continuation, is what gives algebraic effects their real depth.

Suppose the program says:

user = HTTP.get("/user")
print(user.name)

If we stop exactly at HTTP.get("/user"), what remains of the computation? This part remains:

print(user.name)

That remaining computation is the continuation. In plain language, the continuation is simply the rest of the program from the point where the effect occurs. It is what the program was going to do next once the requested operation had produced a result.

This matters because algebraic effects are not only about separating requests from implementations. They are also about controlling what happens to the computation when such a request is made. A handler does not merely say “call this function instead of that one.” It stands at the point where the computation has paused, interprets the effect, and then allows the computation to continue.

At its simplest, the whole model can be described like this:

program runs
->
effect is performed
->
handler interprets it
->
program continues

Or, in the shorter formula that captures the whole intuition:

perform -> handle -> continue

A tiny example makes this feel more concrete. Imagine a very small effect for printing:

effect Console {
print(message)
}

Now suppose the program says:

Console.print("Hello")
Console.print("World")

The program itself does not define what “print” means. A handler might interpret it using the real system console:

handle Console.print(message) {
systemConsole.write(message)
}

But another handler could interpret the same effect differently:

handle Console.print(message) {
testLog.append(message)
}

In the first case, the program writes to the console. In the second, it records messages in memory for testing. The logic of the program has not changed; only the interpretation of the effect has changed.

This is one of the most appealing parts of algebraic effects. The code that expresses the program’s intention can remain stable, while the meaning of its operations can vary with the handler that surrounds it.

At first glance, this may sound close to other abstraction techniques developers already know. Interfaces also separate definition from implementation. Dependency injection also lets us swap behavior. Ports and adapters also distinguish what the program needs from how the environment provides it. Those analogies are useful, but only up to a point. What makes algebraic effects special is that handlers do not only choose an implementation. They sit inside the computational path itself. They interpret the operation at the moment it is performed and determine how the computation reconnects to the rest of the program.

That is why algebraic effects feel so close to exceptions, await, generators, and coroutines. They are not merely about abstracting over services. They are about structuring effectful control flow.

If the terminology still feels heavy, the simplest image to keep is this: the program reaches a point where it needs help, raises a request, something else handles it, and then the computation continues.

That is the core idea.

The next step is to make this even more precise by looking at what actually happens, step by step, when an effect occurs.


What Happens When an Effect Occurs

Once the basic vocabulary is in place (effect, handler, continuation) the next step is to slow down and look at the mechanism itself. What exactly happens when a program performs an effect?

This is where the idea usually stops feeling abstract.

At first, it is easy to think of an effect only as an external action: reading, printing, waiting, failing, fetching, updating state. But the important part is not just the action itself. The important part is what happens to the computation at the moment that action is requested.

A small schema captures the overall shape:

program runs
->
effect is performed
->
handler takes control
->
computation continues

That picture is already useful, but it hides the most important detail: the program does not continue from the beginning, and it does not continue from some vague “next phase.” It continues from the exact point where it had to stop. Something must therefore preserve the rest of the computation.

That preserved “rest” is the continuation.

Let us make this concrete with a tiny example:

user = getUser("/user")
print(user.name)

At first, execution is ordinary. The program starts, reaches getUser("/user"), and appears to be following the usual line-by-line path. But if getUser("/user") is treated as an effect, then the program is not really saying “I know how to fetch this user.” It is saying something more like: “I need this operation to be handled.”

That is the effect request.

The key point is that the computation cannot keep moving forward yet, because it still does not have a value for user. So from the point of view of the effect system, execution has reached a boundary. The program is now effectively in this situation:

user = [effect request]
print(user.name)

The second line cannot run yet, because user does not exist yet. So the computation pauses at the effect point.

It is important to be precise here: this does not necessarily mean that the whole runtime stops. It means that this computation cannot proceed until the requested operation has been interpreted. That distinction matters, especially when we later compare effects with async/await, coroutines, and other suspension mechanisms.

At this point, the runtime or effect system must preserve what the program was going to do next. In our example, what remains is:

print(user.name)

That remaining part is the continuation.

A plain-language definition is enough here: the continuation is the rest of the program from the point where the effect occurs.

So the flow becomes:

program
->
effect occurs
->
rest of computation is captured
->
control goes to handler

This is why algebraic effects are not just about abstraction boundaries. They are about structured interruption of a computation: the program reaches a point where it cannot continue alone, the future of the computation is preserved, control is handed elsewhere, and then the computation resumes.

Now imagine that a handler exists for getUser("/user"). The handler says, in effect: “I know what this operation means here.”

That meaning could vary depending on the context:

  • in production, getUser("/user") may mean “make an HTTP request”
  • in a test, it may mean “return a fake user”
  • in another environment, it may mean “read from cache first”

Conceptually:

perform getUser("/user")
->
handler receives request
->
handler executes interpretation
->
handler produces result

Once the handler has produced a result, the suspended computation can resume. Now user has a value, so the continuation runs:

print(user.name)

This is the full cycle:

user = getUser("/user")
->
effect request
->
continuation captured: print(user.name)
->
handler interprets getUser("/user")
->
result returned
->
continuation resumes

This is why the short formula remains so useful:

perform
->
handle
->
continue

Not:

perform
->
finish somewhere else

Not:

perform
->
wrap everything forever

But:

perform
->
handle
->
continue

That is the structural feeling algebraic effects are trying to preserve.

A second example makes the same idea even simpler. Suppose we have:

name = askName()
print("Hello " + name)

When askName() is performed as an effect, the continuation is roughly:

print("Hello " + name)

A handler can now interpret askName() by providing a value, for example "Héla". Once that happens, the continuation resumes as if the original program had simply received that value:

print("Hello Héla")

This small example matters because it shows that the handler is not merely “doing some work on the side.” It is standing exactly at the point where the computation was interrupted and reconnecting the result to the rest of the program.

That is also why algebraic effects feel related to several mechanisms developers already know:

  • exceptions: the normal path is interrupted and control jumps to a handler.
  • await: the current computation is suspended and resumed later.
  • generators: execution yields and later continues from the same point.
  • React Suspense: rendering cannot continue normally, fallback control takes over, and rendering is later resumed or retried.

These mechanisms are not identical, but they all live in the same family of ideas: a computation reaches a point where it cannot proceed normally, another mechanism takes control, and the future of the computation is handled in a structured way.

The deepest insight to keep from this section is therefore not simply that “an effect is some side operation.” It is that an effect is also a control-flow event. It is a moment where the current computation gives control to something else.

That is why handlers matter so much. They do not only supply implementations. They stand at the point where execution has been interrupted and determine how the computation reconnects to its future.

And that is why continuations are the real secret ingredient. Without them, we would mostly have a cleaner form of dispatch. With them, we have a way to reason about interruption, suspension, delegation, and resumption in a unified way.

A compact summary is enough to carry forward:

  • the computation reaches an effect
  • it cannot continue normally yet
  • the rest of the computation is preserved
  • a handler interprets the request
  • the computation resumes from that exact point

Or, even shorter:

the computation pauses,
asks,
and resumes

That is the mechanism behind the idea.

The next section can now ask a sharper question: if algebraic effects organize effectful computation this way, how is that really different from the way monads organize it?


Algebraic Effects vs Monads

Once algebraic effects are explained in terms of operations, handlers, and continuations, a natural question appears: how are they really different from monads?

This comparison matters because both monads and algebraic effects are trying to address a similar problem. A computation may fail, depend on state, perform IO, or wait for a value that is not available yet. In all these cases, the program is no longer just moving from plain value to plain value. Some structure is needed to make effectful computation manageable.

The similarity is real. But the shape of the solution is different.

A monad can be understood, at a practical level, as a way of representing a computation inside a context. A Promise<User> is not a User; it is a computation that may eventually produce a User. An Either<Error, User> is not simply a User; it is a computation that may produce an error or a user. An IO<User> is not merely a value either; it is a computation that, when run, may perform effects and eventually yield a result.

That changes how the rest of the program must be written.

If getUser() returns a plain value, we can write:

const user = getUser();
saveUser(user);

But if getUser() returns a Promise<User>, the next step cannot simply proceed as before. The continuation has to move into the promise:

getUser().then(user => {
saveUser(user);
});

The same pattern appears with other monads in other forms. Instead of using the result directly, the program continues through operations such as thenmapflatMap, or chain. In that sense, the computation now lives inside the monadic context, and the future of the program must be expressed through that context.

That is the first key contrast.

With monads, the continuation is usually pushed inside the computational structure.
With algebraic effects, the continuation is captured by the handler mechanism.

A tiny side-by-side comparison makes this difference easier to see.

Monad-style continuation

getUser().then(user => {
saveUser(user);
});

Here the continuation is explicit inside .then(...):

user => {
saveUser(user);
}

Effect-style continuation

user = getUser()
saveUser(user)

If getUser() is treated as an effect, then the system captures the continuation at the effect point. What remains is:

saveUser(user)

That continuation is not manually written inside a chaining API. It is preserved and managed by the effect-handling mechanism.

This is why algebraic effects often feel closer to direct style. The program can continue to look like an ordinary sequence of steps, while the handling of the effect is moved elsewhere.

That does not mean monads are a mistake. On the contrary, monads are powerful precisely because they make the context explicit and provide disciplined ways to compose computations. Promises are better than callbacks for many reasons. EitherOption, and IO each solve real problems in a principled way. The issue is not that monads fail to organize effectful code. The issue is that they organize it by reshaping the computation around the context.

That reshaping is where many developers begin to feel friction.

Consider a simple sequence of steps:

const user = getUser();
const orders = getOrders(user);
processOrders(orders);

In a monadic style, the same flow often becomes:

getUser()
.then(user => getOrders(user))
.then(orders => processOrders(orders))
.catch(error => handleError(error));

This is still manageable, and often elegant. But it is no longer written in the shape of the problem itself. It is written in the shape required by the effect machinery. The continuation has been absorbed into the chaining structure.

That pressure becomes stronger when several effects interact at once. A real computation may be asynchronous, fallible, stateful, and logged. Monadic systems can model this, but often by stacking or combining contexts in increasingly structured ways. That remains powerful, but it can also become harder to read, teach, and compose.

Algebraic effects change the place where that composition happens.

Instead of saying:

this whole computation lives inside a context

they say:

this computation may request operations,
and handlers will interpret them

That is a major shift.

Take this direct-looking code:

user = getUser()
orders = getOrders(user)
processOrders(orders)

In an algebraic-effect model, getUser() and getOrders(user) do not have to be understood as ordinary calls whose operational meaning is fixed once and for all. They can be treated as effectful requests. The hidden story becomes:

program asks for getUser()
->
handler decides what getUser() means here
->
program resumes with result
->
program asks for getOrders(user)
->
handler interprets that too

So the code stays close to the problem description, while the operational meaning of the requests is externalized.

A small concrete comparison makes the contrast sharper.

Promise-based style

fetchUser()
.then(user => {
console.log("user fetched");
return saveUser(user);
})
.catch(error => {
console.log("fetch failed");
handleError(error);
});

Effect-style intuition

log("fetching user")
user = fetchUser()
saveUser(user)

In the second case, the code remains linear. The interpretation is pushed outward:

  • log(...) may be handled by a real logger, a tracer, or a test buffer
  • fetchUser() may be handled by HTTP, cache, or a mock
  • failure may be handled by retry logic, by error propagation, or by a fallback handler

The complexity still exists, but it is distributed differently.

This is why people often say algebraic effects help composition. The statement should be made carefully. Monads compose well within their own discipline. That is one of their strengths. The difficulty comes when multiple kinds of effect must coexist and the surrounding structure becomes increasingly shaped by those interacting contexts.

Algebraic effects do not make composition magically trivial. But they do move it into another place. Instead of asking, “How do I stack these wrappers?” we ask, “Which operations may this computation perform, and which handlers interpret them?” That often leads to code that feels less dominated by the representation of the effects themselves.

A compact comparison helps summarize the contrast:

AspectMonadsAlgebraic Effects
Core ideaComputation lives in a contextComputation requests operations
What carries the effectThe wrapped computation or valueThe effect operation itself
How computation continuesThrough thenmapflatMapchain, etc.Through handler-managed continuation
Code shapeOften chained or liftedOften closer to direct style
Main strengthExplicit, disciplined compositionSeparation of logic and interpretation

There is another misconception worth avoiding. Algebraic effects are not simply “monads but easier,” and they are not just dependency injection with a different syntax. What they add is not only another way to separate interface from implementation. What they add is a model in which effectful operations can interrupt computation, be interpreted by handlers, and then reconnect to the continuation.

That is why continuations matter so much here.

Without continuations, we would mostly have a handler-dispatch mechanism.
With continuations, we have a way to structure effectful control flow itself.

That is the real difference.

So the clearest way to remember the contrast is this:

  • a monad says: continue inside this context
  • an algebraic effect says: request this operation; a handler will decide what it means

Both approaches are trying to tame effects. But monads organize the computation around the context, while algebraic effects organize the interpretation around the computation.

That is why they feel related, yet not the same.

The next section makes this difference more concrete by building a tiny effect system in JavaScript.


A Simple JavaScript Implementation

At this point, the ideas are clear enough that it helps to make them concrete. The easiest way to do that is not to jump immediately into a language with native support for algebraic effects, but to build a tiny effect system in plain JavaScript.

This implementation will be deliberately small. It will not be a full algebraic-effect runtime, and it will not support resumable continuations. But it will already let us see the essential separation that makes the model attractive:

  • the program performs an operation
  • a handler decides what that operation means
  • the result comes back to the program

That is enough to make the core intuition tangible.

Suppose we want to write code like this:

const user = Effects.getUser(1);
Effects.print(`Hello ${user.name}`);

The important point is that the program itself should not know how getUser or print are implemented. It should only express that it needs those operations. The interpretation should be supplied from the outside.

A simple way to model this is to keep a stack of handler scopes:

const handlerStack = [];

Why a stack? Because effect handling is usually scoped. A handler is active only in a certain dynamic region of execution, and if an effect is not handled in the nearest scope, the search continues outward. That already gives us a structure very close to the intuition of nested handlers.

To install a handler scope around a piece of code, we can define:

function withHandler(handlers, fn) {
handlerStack.push(handlers);
try {
return fn();
} finally {
handlerStack.pop();
}
}

This creates a dynamic handling context. While fn() runs, the handlers are active. When execution leaves that scope, they are removed again, even if an error occurs. The runtime picture is therefore:

outer handlers
->
inner handlers
->
program

Now we need the operation that performs an effect request. A very small version looks like this:

function perform(effectName, payload) {
for (let i = handlerStack.length - 1; i >= 0; i--) {
const handlers = handlerStack[i];
if (effectName in handlers) {
return handlers[effectName](payload);
}
}
throw new Error(`Unhandled effect: ${effectName}`);
}

This function does the essential work. When perform(...) is called, it searches from the innermost handler scope outward. If it finds a matching handler, it executes it and returns the result. If no handler is found, it throws an explicit error.

That means an effect request never executes itself. It always asks the currently active handler environment for an interpretation.

To make the surface API nicer, we can wrap those raw effect names in an object:

const Effects = {
print: (message) => perform("print", message),
getUser: (id) => perform("getUser", { id }),
};

Now the program can look more direct:

function program() {
const user = Effects.getUser(1);
Effects.print(`Hello ${user.name}`);
}

A first full example now becomes:

const handlerStack = [];
function withHandler(handlers, fn) {
handlerStack.push(handlers);
try {
return fn();
} finally {
handlerStack.pop();
}
}
function perform(effectName, payload) {
for (let i = handlerStack.length - 1; i >= 0; i--) {
const handlers = handlerStack[i];
if (effectName in handlers) {
return handlers[effectName](payload);
}
}
throw new Error(`Unhandled effect: ${effectName}`);
}
const Effects = {
print: (message) => perform("print", message),
getUser: (id) => perform("getUser", { id }),
};
function program() {
const user = Effects.getUser(1);
Effects.print(`Hello ${user.name}`);
}
withHandler(
{
getUser: ({ id }) => ({ id, name: "Héla" }),
print: (message) => console.log(message),
},
program
);

The output is simply:

Hello Héla

The interesting part is not the output itself, but what it reveals about the structure. The program says:

const user = Effects.getUser(1);
Effects.print(`Hello ${user.name}`);

Yet the program does not know whether getUser reads from memory, calls an API, hits a database, or returns a mock object. It also does not know whether print writes to the real console or appends to a test buffer. It only performs operations. The handler scope decides what those operations mean in that execution context.

That is already enough to show the first major benefit of the model: the logic of the program stays the same while the interpretation of its operations can be swapped.

For example, the exact same program() can be run in two different environments.

A production-style interpretation:

withHandler(
{
getUser: ({ id }) => ({ id, name: "Real User" }),
print: (message) => console.log(message),
},
program
);

And a test-style interpretation:

const logs = [];
withHandler(
{
getUser: ({ id }) => ({ id, name: "Mock User" }),
print: (message) => logs.push(message),
},
program
);
console.log(logs);

In the second case, the program runs without touching the real console at all. The core logic is unchanged; only the handler interpretation has changed.

The stack model becomes even more interesting once handlers are nested:

withHandler(
{
print: (message) => console.log("[outer print]", message),
},
() => {
withHandler(
{
getUser: ({ id }) => ({ id, name: "Inner User" }),
},
() => {
const user = Effects.getUser(42);
Effects.print(`Hello ${user.name}`);
}
);
}
);

The output is:

[outer print] Hello Inner User

Why? Because getUser is handled by the inner scope, while print is not, so it bubbles outward until the outer scope handles it.

The execution looks like this:

perform(getUser, 42)
->
inner handler found
->
return { id: 42, name: "Inner User" }
perform(print, ...)
->
no inner print handler
->
outer handler found
->
console.log(...)

This is important because it makes the handler search strategy visible: nearest scope first, then outward.

It is also useful to see what happens when no handler exists:

const ReadConfig = {
get: (key) => perform("readConfig", key),
};
function program() {
const value = ReadConfig.get("API_URL");
console.log(value);
}
program();

This produces:

Error: Unhandled effect: readConfig

That behavior is desirable. An effect that has no interpretation should fail loudly rather than silently doing nothing.

Even though this runtime is tiny, it already shows several essential properties:

  • the program performs operations without defining them
  • handlers interpret those operations
  • handlers can be swapped
  • handlers can be nested
  • unhandled effects are explicit

That is already enough to show the separation between requesting an operation and interpreting it.

At the same time, we should be clear about what this implementation does not show yet. The handler only receives the effect name and payload. It does not receive the continuation. So it can return a value immediately, but it cannot yet say:

  • continue later
  • continue more than once
  • cancel the continuation
  • transform the future of the computation directly

In other words, this tiny JavaScript system is effect-like, but it is still only the surface of algebraic effects.

A full handler is closer to this idea:

handle operation(payload, continuation)

not just:

handle operation(payload)

That missing continuation is exactly what turns effect dispatch into true resumable effect handling.

Still, this small runtime is a very useful step. It proves that even without advanced runtime machinery, we can already see the core shift:

program expresses the operation
->
runtime finds a handler
->
handler gives the operation meaning
->
program continues

That is the essence of the model.

The next section introduces the missing piece directly: continuations, the ingredient that turns effect handlers from simple interpreters of operations into interpreters of computation itself.


Continuations: The Secret Ingredient

Up to this point, it is still possible to misunderstand algebraic effects in a very reasonable way. One might think that the whole idea is simply this: the program declares operations, and handlers provide implementations. That is already useful, and it already gives us a cleaner separation between what the program asks for and how the environment provides it. But if that were the whole story, algebraic effects would still feel close to interfaces, dependency injection, or ports and adapters. They would be elegant, but not fundamentally different.

The real shift begins when continuations enter the picture.

A continuation is one of those words that sounds more mysterious than the idea itself. In the simplest possible terms, a continuation is just what the program was going to do next.

Take a tiny example:

name = askName()
print("Hello " + name)

If execution reaches askName(), what remains after that point? The rest of the computation is:

print("Hello " + name)

That remaining computation is the continuation.

So when we say that an effect handler receives a continuation, what we really mean is this: the handler does not only receive the requested operation, it also receives the suspended future of the computation, the part of the program that has not run yet.

That changes the power of the handler completely.

Without continuations, a handler behaves like a normal implementation. The program asks for an operation, the handler runs some code, and it returns a result. That is useful, but it is still close to ordinary call-and-return programming. The future of the computation remains implicit and ordinary.

With continuations, the handler stands exactly at the point where the computation has been interrupted. It can now decide not only how to interpret the operation, but also how the computation should proceed afterward.

That is why continuations are the secret ingredient.

A small example makes this easier to feel. Suppose the program says:

user = getUser()
print(user.name)

If getUser() is a normal function, execution just enters the function, obtains a value, and continues. But if getUser()is treated as an effect, something different happens. The computation reaches a point where it cannot continue until user exists. So the effect system captures the rest of the computation (in this case print(user.name)) and gives that continuation to the handler.

Conceptually, the handler now looks more like this:

handle getUser(payload, continuation)

The continuation is the suspended remainder of the program, waiting for a value.

Now imagine that the handler decides to provide this user:

{ name: "Héla" }

The continuation then resumes as if the original program had simply received that value:

print("Héla")

This already shows something important: the handler is not merely returning data to an ordinary function call. It is taking responsibility for how the interrupted computation reconnects to the future of the program.

A tiny pseudocode version makes the mechanism even clearer:

name = askName()
print("Hello " + name)

A continuation-aware handler could be imagined like this:

askName(_, k) {
continue k("Héla")
}

Here k is the continuation. It represents the rest of the computation. When the handler says continue k("Héla"), the program resumes as if askName() had produced "Héla" at that exact point. The resulting behavior is:

print("Hello Héla")

At first glance, this can look like only a slightly more complicated way of returning a value. But the difference is deeper than that, because once the handler has access to the continuation, it is no longer limited to one simple behavior.

A handler may resume immediately. In that case, the effect feels almost like an ordinary function call.

getConfig(key, k) {
if (key == "timeout") continue k(5000)
}

If the program says:

timeout = getConfig("timeout")
print(timeout)

then the continuation resumes immediately with 5000, and execution continues.

But a handler may also resume later. Suppose the program says:

wait(1000)
print("done")

A continuation-aware handler might conceptually do this:

wait(ms, k) {
setTimeout(() => continue k(), ms)
}

Now the continuation is preserved and resumed later. The program pauses at the effect point, the handler schedules the resumption, and only afterward does the rest of the computation continue. This is already very close to the intuition behind async/await.

A handler may also decide not to resume at all. Suppose the program says:

authorize(user)
print("Access granted")

A handler could choose:

authorize(user, k) {
if (!user.isAllowed) throw "Access denied"
continue k()
}

Now the handler has not only implemented authorize; it has decided the fate of the future computation. The continuation runs only if access is granted.

In some systems, a handler may even resume the continuation more than once. That opens the door to behaviors such as nondeterminism, backtracking, or multiple possible execution paths. Even without going deeply into those cases, the pattern is already visible: once a handler has access to the continuation, it is no longer merely an implementation of an operation. It becomes an interpreter of the computation’s future.

That is the decisive step.

A normal implementation answers the question:

what value should this function return?

A continuation-aware handler answers the larger question:

what should happen to the rest of the program from this point onward?

This is why continuations make algebraic effects more powerful than simple abstraction techniques. Interfaces, dependency injection, and ports and adapters can all separate what is requested from how it is implemented. But they do not usually take control of the suspended future of the computation itself.

That also explains why continuations help unify many mechanisms that often seem unrelated.

With exceptions, the normal continuation is abandoned and control jumps elsewhere:

try {
riskyOperation();
console.log("done");
} catch (error) {
handleError(error);
}

If riskyOperation() throws, the continuation console.log("done") does not run. A different control path takes over.

With async/await, the continuation is suspended and resumed later:

const response = await fetchUser();
console.log(response.name);

At await, the rest of the computation is roughly:

console.log(response.name);

That future is preserved until the promise resolves or rejects.

With generators, the pause-and-resume structure becomes even more explicit:

function* program() {
const value = yield "need value";
console.log(value);
}

When execution reaches yield, the computation pauses. Later, next(value) resumes it from the same point.

Even React Suspense belongs to the same broad family. A component reaches a point where it cannot continue rendering because some resource is not ready. Rendering is interrupted, control passes to the Suspense boundary, a fallback appears, and later rendering is retried or resumed when the resource becomes available.

These mechanisms are not identical, but they share a common shape:

computation reaches a boundary
->
normal flow cannot continue
->
another mechanism takes control
->
the future of the computation is managed externally

This is the level at which algebraic effects become genuinely interesting. They are not only about side effects in the everyday sense. They are about the structure of computation itself: what happens when a program cannot continue normally and some other mechanism must decide how its future should unfold.

That is why algebraic effects are often described as a way of making control flow programmable.

A handler with access to a continuation is not simply implementing a service. It is standing inside the execution path of the program. It can allow the future of the computation to proceed now, later, differently, or not at all.

This is also why algebraic effects are deeper than interfaces alone.

An interface can tell us what can be called.
An implementation can tell us how that call behaves.
A continuation-aware handler can also tell us how the rest of the computation proceeds after that call.

That last step is the decisive one.

If continuations were removed from the picture, algebraic effects would still be useful, but they would feel much closer to runtime dispatch or dynamic dependency injection. Once continuations are included, they become something richer: a model that can explain exceptions, asynchronous suspension, resumable computations, and many other control-flow behaviors through one common idea.

So if one sentence should remain from this whole section, it is this:

Continuations are what turn effect handlers from interpreters of operations into interpreters of computation itself.

That is why they are the secret ingredient.

The next section widens the lens and connects algebraic effects to mechanisms developers already know: exceptions, async/await, generators, coroutines, React Suspense, and ports-and-adapters style design.


Relationship to Existing Mechanisms

Algebraic effects become much easier to understand once we stop treating them as an isolated idea and begin comparing them to mechanisms developers already know. They are not the same as exceptions, async/await, generators, coroutines, or React Suspense, but they are close enough in structure that each of them can illuminate one side of the model.

The common pattern is this:

normal computation
->
something interrupts or suspends it
->
another mechanism takes control
->
computation continues differently

That pattern appears again and again in modern programming. Algebraic effects are interesting partly because they offer a more unified way to think about it.

Exceptions are the simplest place to start.

try {
riskyOperation();
console.log("done");
} catch (error) {
console.log("handled:", error.message);
}

If riskyOperation() throws, the normal path does not continue. Instead of moving forward to console.log("done"), control jumps outward until a matching catch block handles the situation.

riskyOperation()
->
throw error
->
catch handler
->
new control path

This is already effect-like in an important sense. An operation interrupts the normal flow, and a handler decides what happens next. The major difference is that exceptions are usually non-resumable. Once the error is thrown, the original continuation is abandoned rather than resumed from the exact throw point. So exceptions do not give us the full generality of algebraic effects, but they clearly belong to the same family of control-flow mechanisms.

async/await shows another side of that family.

const user = await fetchUser();
console.log(user.name);

Here the computation is not abandoned. It is suspended. At await, the rest of the program is roughly:

console.log(user.name);

That remainder is the continuation. The runtime preserves it, waits until the promise settles, and then resumes the computation with the resulting value or error.

await fetchUser()
->
pause current computation
->
runtime waits
->
resume with value
->
console.log(user.name)

This is why async/await feels so close to the intuition behind algebraic effects. The code looks direct, but under the surface the runtime is doing something continuation-like: preserving the future of the computation and restoring it later. The difference is that await is a specialized, built-in suspension mechanism tied to promises. Algebraic effects aim at a more general model where many such operations can be expressed through handlers rather than through one special-purpose construct.

Generators make the same structure even more visible.

function* program() {
const value = yield "need value";
console.log(value);
}

When yield is reached, execution pauses. Later, someone calls:

iterator.next("hello");

and the computation resumes from the exact point where it stopped.

yield
->
pause generator
->
external caller decides when to continue
->
next(value)
->
resume computation

Generators are not algebraic effects, but they are a very good intuition bridge. They make it obvious that execution can be suspended, preserved, and resumed from the same place. Algebraic effects build on a similar control-flow intuition, but organize it around named operations and handlers rather than around the generator protocol.

Coroutines are even closer in spirit. They also let computation pause, give control away, and resume later.

coroutine A running
->
suspend
->
coroutine B or scheduler runs
->
resume A later

Here again, the resemblance is structural. Both coroutines and algebraic effects treat execution as something that can be paused and resumed. The difference is that coroutines are usually a lower-level control mechanism, while algebraic effects add a more structured model: computations perform named operations, and handlers interpret them. In that sense, a useful shorthand is:

coroutines = pause/resume mechanism
effects = pause/resume + named operations + handlers

React Suspense shows the same family resemblance in a domain-specific form. A component tries to render, but some resource is not ready. Rendering cannot continue in the normal way, so a Suspense boundary takes control, displays fallback UI, and later retries or resumes rendering when the resource becomes available.

component render
->
resource not ready
->
Suspense boundary takes control
->
fallback shown
->
resource becomes ready
->
render retried

This is not a general-purpose algebraic effect system, but it clearly explores the same shape: normal computation reaches a boundary, another mechanism takes control, and the future of that computation is managed externally.

There is also an architectural analogy that is useful, even if it is incomplete: ports and adapters.

Program -> Logger interface -> ConsoleLogger

or more abstractly:

program needs operation
->
port defines it
->
adapter implements it

This resembles algebraic effects because the program expresses what it needs, and another layer decides how that need is fulfilled. But the similarity stops at the abstraction boundary. Ports and adapters separate request from implementation; algebraic effects can also influence the control flow that follows the request. That extra continuation-aware dimension is what makes them deeper than a simple architectural substitution pattern.

A compact summary helps keep the differences visible:

MechanismWhat happens?Closest effect intuition
ExceptionsExecution jumpsA handler changes the control path
async/awaitComputation pauses and resumesThe runtime handles suspension
GeneratorsExecution yields and later resumesThe continuation is preserved
CoroutinesCooperative pause/resumeControl is externally scheduled
React SuspenseRendering pauses and retriesA boundary handles incomplete computation
Ports & AdaptersAn operation is implemented elsewhereRequest is separated from implementation

What matters here is not claiming that all these mechanisms are secretly the same thing. They are not. Their APIs, purposes, and runtime models differ in important ways. What matters is that they all explore part of the same deeper question:

What happens when a computation cannot continue normally by itself?

Algebraic effects are interesting because they try to give one general model for that situation:

perform operation
->
handler takes control
->
computation continues

That is why they feel connected to so many familiar ideas, even though none of those ideas is identical to them.

The next section makes this even more concrete by looking at languages that expose algebraic effects directly.


Real Languages Using Algebraic Effects

Algebraic effects become much easier to trust once we see that they are not only theoretical objects. Several languages expose them directly, although not always under the same name. Some make effect handlers a first-class feature; others present closely related ideas through “abilities” or similar constructs. What matters is that the same structural pattern keeps reappearing:

declare operation
->
perform operation
->
handler intercepts it
->
computation resumes

That pattern is visible in Koka, OCaml 5, Unison, and Eff. Each language presents it differently, and those differences are useful because they show that algebraic effects are not tied to one syntax or one implementation style.

Koka: explicit effect operations and with handler

Koka is one of the clearest languages to read for this topic because the syntax is direct and the official book presents effect handlers as a central feature. The documentation explicitly says that effect handlers let you define advanced control abstractions such as exceptions, async/await, iterators, parsers, ambient state, and probabilistic programs in a typed and composable way. (koka-lang.github.io)

A small example from the Koka book looks like this:

// declare an abstract operation: emit, how it emits is defined dynamically by a handler.
effect fun emit(msg : string) : ()
// emit a standard greeting.
fun hello() : emit ()
emit("hello world!")
pub fun hello-console1() : console ()
with handler
fun emit(msg) println(msg)
hello()

This example is valuable because it is extremely close to the intuitive story behind algebraic effects. The function hello() performs emit("hello world!"), but it does not say what emit concretely means. The surrounding with handler block gives emit an interpretation by mapping it to println(msg). In other words, the program expresses the operation, while the handler determines how that operation is realized in this context. (koka-lang.github.io)

The shape is easy to see:

hello()
->
emit("hello world!")
->
handler intercepts emit
->
println(msg)

Koka is therefore a very good language to study when introducing the topic, because it makes the handler model lightweight and readable without hiding the fact that effects are part of the type and control-flow story. (koka-lang.github.io)

OCaml 5: performtry_with, and explicit continuations

OCaml 5 is important because it brings effect handlers into a serious general-purpose language. The official manual shows effects declared by extending Effect.t, performed with perform, and handled through Effect.Deep.try_withor match_with. Most importantly, the continuation is explicitly present in the API and resumed with continue. (OCaml)

A representative OCaml example from the manual is:

open Effect
open Effect.Deep
type _ Effect.t += Xchg : int -> int t
let comp1 () =
perform (Xchg 0) + perform (Xchg 1)
let result =
try_with comp1 () {
effc = fun (type a) (eff : a t) ->
match eff with
| Xchg n ->
Some (fun (k : (a, _) continuation) ->
continue k (n + 1))
| _ -> None
}

Several things are worth noticing here.

First, the effect itself is declared explicitly:

type _ Effect.t += Xchg : int -> int t

This says that Xchg is an effect operation that takes an int and eventually produces an int. Then perform (Xchg 0)triggers that effect inside the computation. Finally, the handler case receives a continuation k and resumes it with continue k (n + 1). The manual also notes that returning None for an unmatched effect forwards it to an outer handler, which makes the nesting behavior explicit. (OCaml)

The handler API itself is illuminating:

type ('a, 'b) handler = {
retc : 'a -> 'b;
exnc : exn -> 'b;
effc : 'c. 'c Effect.t -> (('c, 'b) continuation -> 'b) option;
}

This is one of the best concrete APIs to show in an article because it makes the continuation impossible to ignore. The handler is not just a mapping from an effect to a result; its effc field receives an effect and may return a function that takes a continuation. That is precisely the mechanism that lets handlers decide how the suspended computation should resume. (OCaml)

The flow in the example is therefore:

perform (Xchg 0)
->
effc matches Xchg
->
handler receives continuation k
->
continue k (0 + 1)
->
computation resumes

OCaml’s manual also distinguishes deep and shallow handlers, which is useful because it shows that even within one language, the design space of effect handling can vary. Deep handlers continue to handle future effects performed by the resumed continuation, whereas shallow handlers only intercept one layer and stop. That detail reinforces an important point: once continuations are exposed, effect handling becomes a rich control-flow mechanism, not just a replacement for dependency injection. (OCaml)

Unison: abilities, handle ... with ..., and Request

Unison uses the term abilities rather than “effects,” but the official documentation explicitly says that Unison’s system of abilities is often called algebraic effects in the literature. It also states that abilities let programs use ordinary syntax for asynchronous I/O, stream processing, exception handling, parsing, distributed computation, and more. (unison-lang.org)

An ability declaration in Unison can look like this:

structural ability Store a where
get : {Store a} a
put : a ->{Store a} ()

The docs explain that this defines an ability Store together with operations such as Store.get and Store.put, and that these operations become part of the type of computations that use them. In other words, the request is reflected directly in the type signature. (unison-lang.org)

A concrete handler from the Unison docs looks like this:

storeHandler : v -> Request (Store v) a -> a
storeHandler storedValue = cases
{Store.get -> k} ->
handle k storedValue with storeHandler storedValue
{Store.put v -> k} ->
handle k () with storeHandler v
{a} -> a

This example is especially valuable because the continuation is visible right in the pattern as k. In the Store.get case, the handler resumes the computation with the current stored value; in the Store.put v case, it updates the state and resumes with (). The docs also explain that the core handler form is:

handle e with h

and that if e has type {A} T and h has type Request A T -> R, then handle e with h has type R. This makes the handling model extremely explicit: computations request abilities, and handlers consume a Request that includes the continuation. (unison-lang.org)

The flow is therefore:

Store.get
->
handler matches {Store.get -> k}
->
handler calls k storedValue
->
computation resumes with storedValue

Unison is particularly useful in an article like this because it makes two things visible at once:

  • the requested abilities are part of the computation’s type
  • the continuation appears directly in the handler pattern as k (unison-lang.org)

Eff: the reference language

Eff is historically important because it was designed around algebraic effect handlers from the start. Its official site describes Eff as a functional programming language based on algebraic effect handlers and says this allows handlers not only for exceptions, but for any computational effect, including redirected output, transactional state updates, and asynchronous thread scheduling. (eff-lang.org)

Eff matters in the article less because of one specific syntax example and more because it represents the “reference shape” of the idea. The language was built around the handler model itself, rather than adding it later as an advanced feature. That gives it a special place in the history of algebraic effects. The official site also points readers to “An Introduction to Algebraic Effects and Handlers,” which underlines its role as a conceptual home for the subject. (eff-lang.org)

A useful way to summarize Eff is:

define effect
->
write program using it
->
write handler later
->
choose the behavior you want

That is exactly the separation the whole article has been building toward.

Side-by-side comparison

A compact comparison helps keep the differences visible:

LanguageDeclares effects/abilitiesPerforms operationHandles operationContinuation visible?
Kokaeffect fun emit(...)direct call like emit(...)with handler ...implicit in the handler model (koka-lang.github.io)
OCaml 5type _ Effect.t += ...perform (...)try_with / match_withyes, via k and continue (OCaml)
Unisonability ... whereoperations like Store.gethandle e with hyes, pattern-bound as k in handlers (unison-lang.org)
Effcustom effects and handlerseffect operationhandleryes, in the language’s handler model (eff-lang.org)

What to keep from this section

The most important lesson is not memorizing each language’s syntax. It is noticing that the same deep structure keeps reappearing:

declare operation
->
perform operation
->
handler intercepts it
->
continuation resumes

Koka makes this feel lightweight and readable. OCaml 5 makes the continuation API explicit. Unison shows how requests and handlers can be part of the type system. Eff stands as the classic language built around the idea from the beginning. Taken together, they show that algebraic effects are not just a theoretical curiosity; they are a real and evolving design direction in programming languages. (koka-lang.github.io)

The next section can now answer the practical question that naturally follows: why do algebraic effects matter?


Why Algebraic Effects Matter

After all the definitions, comparisons, and language examples, one practical question remains: why should anyone care?

Why introduce another abstraction into programming, especially one that already sounds more advanced than everyday code?

The answer is not that algebraic effects magically remove complexity. They do not. Fetching data is still hard. Failure still exists. Concurrency is still difficult. State is still subtle. What algebraic effects try to improve is something more structural: where that complexity lives, and how much it distorts the shape of the program.

That is where their value appears.

A first advantage is that they let code stay closer to the shape of the problem itself. Suppose the actual logic of a computation is simple:

user = getUser()
orders = getOrders(user)
printSummary(orders)

That is the problem in its natural form. But once effects enter the picture, code often starts bending around the machinery required to execute those effects. In a promise-based style, the same flow may become:

getUser()
.then(user => getOrders(user))
.then(orders => printSummary(orders))
.catch(error => handleError(error));

This is good code, and in many cases it is exactly the right tool. But the structure is no longer only about the business logic. It is also about sequencing asynchronous work, propagating failures, and placing the continuation inside the chain. Algebraic effects try to preserve more of the first shape:

user = getUser()
orders = getOrders(user)
printSummary(orders)

while moving questions such as these into handlers:

  • how getUser() is executed
  • how getOrders(user) is executed
  • what happens on failure
  • whether retries, logging, tracing, or fallback behavior apply

That is one of their biggest strengths:

logic stays in the program
execution strategy moves to handlers

A second advantage is that they separate what the program wants from how the surrounding world provides it. This resembles ports and adapters, but it goes further because the separation is not only architectural; it also reaches into control flow.

A program may look like this:

log("fetching user")
user = fetchUser()
saveUser(user)

At the level of intent, this is simple. The computation wants to log a step, fetch a user, and save the result. But many operational questions remain open:

  • should log(...) write to the console, a file, a tracer, or a test buffer?
  • should fetchUser() go to HTTP, a cache, or a mock?
  • should saveUser(user) be wrapped in a transaction?

With algebraic effects, those decisions can be externalized into handlers:

program asks
->
handlers interpret
->
program continues

This becomes especially valuable when the same computation must run in multiple environments.

same program
->
production handlers
-> real HTTP, real DB, real logs
same program
->
test handlers
-> fake HTTP, in-memory DB, captured logs

The core logic remains stable while the interpretation changes with the context.

A third reason algebraic effects matter is composition. The hard part of effectful programming is rarely one effect in isolation. Real programs combine many: asynchronous work, failure, state, logging, retries, transactions, caching, tracing, and more. When these concerns are encoded directly into the structure of the computation, the code can become harder to read and harder to combine.

Algebraic effects change where composition happens. Instead of asking:

how do I combine all these wrappers or contexts?

they encourage a different question:

which operations may this computation request?
which handlers will interpret them?

That leads to another picture:

computation
->
may perform:
log
fetch
fail
update state
->
handlers layer around it

The implementation details can still be hard, but the conceptual structure is cleaner. The computation stays focused on its logical steps, while surrounding handlers determine how those requests are interpreted.

A fourth advantage is conceptual. Many features in modern programming languages are usually learned as separate topics:

  • exceptions
  • async/await
  • generators
  • coroutines
  • state handlers
  • backtracking
  • retries
  • UI suspension patterns

Once algebraic effects click, many of these mechanisms start to look less isolated. They can be understood through one shared pattern:

perform operation
->
computation is interrupted or suspended
->
handler takes control
->
computation continues in a structured way

That does not mean they all become the same thing. They do not. But it does mean the mental landscape becomes more unified. Instead of juggling many unrelated models, we start recognizing different specialized forms of one deeper idea.

A fifth advantage is that algebraic effects make continuations more usable without forcing most developers to work directly with continuation theory. Continuations are among the deepest ideas in programming languages, but they are not always pleasant to expose raw. Algebraic effects package them into a more practical model built around named operations, scoped handlers, and explicit interpretation points.

So instead of saying:

manipulate the future of the computation directly

the language can offer something closer to:

declare the operation
handle it here
resume the computation this way

That makes powerful control-flow ideas much more approachable.

A sixth reason they matter is practical testing and experimentation. Suppose the program says:

user = fetchUser()
log("fetched")
saveUser(user)

If these are effectful operations, the same code can be interpreted very differently depending on the handlers around it.

Production interpretation

fetchUser() -> real HTTP
log(...) -> real logger
saveUser() -> real database

Test interpretation

fetchUser() -> fixed mock value
log(...) -> append to test list
saveUser() -> in-memory storage

That gives us:

one computation
->
different handlers
->
different runtime behavior

This is not something only algebraic effects can do, dependency injection can also help here, but effect handlers make the substitution more local, more compositional, and more tightly tied to the operations the computation actually performs.

Finally, algebraic effects encourage a cleaner separation between logic and operational policy. Many systems mix together two concerns that should often remain distinct:

  • the logical steps of the computation
  • the policies that govern execution

Those policies may include retry behavior, timeout behavior, tracing, logging, fallback behavior, or transaction boundaries. Without a clear place for them to live, they tend to spread through business code.

Algebraic effects suggest another arrangement:

computation:
request data
validate it
save it
return result

while handlers define policies such as:

on failure -> retry 3 times
on log -> send to tracer
on DB save -> wrap in transaction

That is where the abstraction becomes architecturally interesting. It provides a cleaner place for operational concerns without forcing the core computation to be full of infrastructure details.

A small side-by-side example makes this concrete.

Without algebraic effects, a simple program may gradually fill with execution machinery:

logger.info("start");
fetchUser()
.then(user => db.save(user))
.then(() => logger.info("done"))
.catch(error => {
logger.error(error);
throw error;
});

With algebraic effects, the same idea can be separated differently:

program:
log("start")
user = fetchUser()
saveUser(user)
log("done")
handlers:
log -> where logs go
fetchUser -> how data is fetched
saveUser -> how persistence works
fail -> retry? stop? transform?

The complexity has not disappeared. It has been redistributed.

That is the real value.

A compact summary helps:

ValueWhat it brings
Direct styleCode stays closer to the problem
Separation of concernsLogic and execution policy are easier to separate
Better modularityHandlers can be swapped by context
Unified mental modelMany control-flow mechanisms become easier to relate
Better experimentationThe same computation can run under different interpretations

The honest point to remember is this:

algebraic effects do not remove complexity
->
they reorganize it

That reorganization is often enough to change how an entire program feels. It can make code read more like the problem it solves, while moving effect interpretation and execution policy into handlers.

That is why algebraic effects matter: not because they abolish side effects, but because they offer a cleaner way to structure effectful computation.

The next section shows how these ideas can be made concrete by building a minimal typed effect-handler runtime in TypeScript.


A Minimal TypeScript Implementation of Effect Handlers

To make algebraic effects feel less abstract, it helps to build a very small implementation in a language many readers already know. The goal here is not to reproduce a full algebraic-effect runtime like Koka or OCaml 5. It is simply to capture the core intuition in a form that feels real:

  • a program performs an operation
  • a handler gives that operation a meaning
  • the program continues with the result

A minimal user-facing API can look like this:

const Print = createEffect<string, void>("Print");
const GetUser = createEffect<number, { id: number; name: string }>("GetUser");
function program() {
const user = perform(GetUser, 1);
perform(Print, `Hello ${user.name}`);
}
withHandler(
[
on(GetUser, (id) => ({ id, name: "Héla" })),
on(Print, (msg) => {
console.log(msg);
}),
],
program
);

This already captures the core feeling of effect handlers:

program performs operation
->
handler interprets it
->
program continues

The program does not know how GetUser or Print are implemented. It simply requests those operations. The surrounding handler scope decides what they mean.

Low-level runtime structure

Under this API, the runtime can remain very small. We need three ingredients:

  • a typed effect descriptor
  • an internal handler representation
  • a dynamically scoped handler stack

The effect descriptor is what the user sees:

type Effect<P, R> = {
key: symbol;
name: string;
};

The internal handler is what the runtime stores:

type UnsafeHandler = {
effectKey: symbol;
effectName: string;
handle: (payload: unknown) => unknown;
};

And the runtime keeps a stack of active handler scopes:

const handlerStack: UnsafeHandler[][] = [];

This split matters because TypeScript’s type information disappears at runtime. The public API can stay typed, while the runtime uses a simpler erased representation.

Declaring effects

Creating an effect is straightforward:

function createEffect<P, R>(name: string): Effect<P, R> {
return {
key: Symbol(name),
name,
};
}

For example:

const Print = createEffect<string, void>("Print");
const GetUser = createEffect<number, { id: number; name: string }>("GetUser");

Each effect gets a unique runtime identity through its symbol.

Registering handlers

A handler is then attached to an effect:

function on<P, R>(
effect: Effect<P, R>,
handler: (payload: P) => R
): UnsafeHandler {
return {
effectKey: effect.key,
effectName: effect.name,
handle: (payload: unknown) => handler(payload as P),
};
}

That gives us a typed surface API like:

on(Print, (msg) => console.log(msg))

while still storing handlers in a runtime-friendly format.

Dynamic handler scope

The function withHandler(...) installs a temporary handler scope around a computation:

function withHandler<T>(handlers: UnsafeHandler[], fn: () => T): T {
handlerStack.push(handlers);
try {
return fn();
} finally {
handlerStack.pop();
}
}

This means handlers are active only while fn() runs. It also means scopes can be nested.

The runtime picture is therefore:

outer handlers
->
inner handlers
->
program

When an effect is performed, the search starts from the innermost handler scope and moves outward.

Performing an effect

The heart of the runtime is perform(...):

function perform<P, R>(effect: Effect<P, R>, payload: P): R {
for (let scopeIndex = handlerStack.length - 1; scopeIndex >= 0; scopeIndex--) {
const scope = handlerStack[scopeIndex];
for (let handlerIndex = 0; handlerIndex < scope.length; handlerIndex++) {
const handler = scope[handlerIndex];
if (handler.effectKey === effect.key) {
return handler.handle(payload) as R;
}
}
}
throw new Error(`Unhandled effect: ${effect.name}`);
}

This function does three things:

  1. it searches the nearest active handler scopes first
  2. it executes the first matching handler
  3. it throws if no handler exists

So a call such as:

perform(Print, "hello");

behaves like this:

perform(Print, "hello")
->
search handler stack
->
find nearest Print handler
->
execute handler
->
return result

Full MVP runtime

Putting everything together gives a complete minimal runtime:

type Effect<P, R> = {
key: symbol;
name: string;
};
type UnsafeHandler = {
effectKey: symbol;
effectName: string;
handle: (payload: unknown) => unknown;
};
const handlerStack: UnsafeHandler[][] = [];
export function createEffect<P, R>(name: string): Effect<P, R> {
return {
key: Symbol(name),
name,
};
}
export function on<P, R>(
effect: Effect<P, R>,
handler: (payload: P) => R
): UnsafeHandler {
return {
effectKey: effect.key,
effectName: effect.name,
handle: (payload: unknown) => handler(payload as P),
};
}
export function withHandler<T>(handlers: UnsafeHandler[], fn: () => T): T {
handlerStack.push(handlers);
try {
return fn();
} finally {
handlerStack.pop();
}
}
export function perform<P, R>(effect: Effect<P, R>, payload: P): R {
for (let scopeIndex = handlerStack.length - 1; scopeIndex >= 0; scopeIndex--) {
const scope = handlerStack[scopeIndex];
for (let handlerIndex = 0; handlerIndex < scope.length; handlerIndex++) {
const handler = scope[handlerIndex];
if (handler.effectKey === effect.key) {
return handler.handle(payload) as R;
}
}
}
throw new Error(`Unhandled effect: ${effect.name}`);
}

Final execution flow

Using that runtime, the earlier example executes like this:

withHandler(...)
->
push handlers onto stack
->
run program()
program()
->
perform(GetUser, 1)
->
runtime finds GetUser handler
->
handler returns { id: 1, name: "Héla" }
->
program resumes
program()
->
perform(Print, "Hello Héla")
->
runtime finds Print handler
->
console.log("Hello Héla")
->
program ends
withHandler(...)
->
pop handlers from stack

The output is:

Hello Héla

That is enough to make the effect model concrete. The program does not know how GetUser or Print are implemented. It only performs operations. The handlers interpret them.

Nested handlers

One advantage of the handler stack is that nested scopes behave naturally:

const Print = createEffect<string, void>("Print");
const GetUser = createEffect<number, { id: number; name: string }>("GetUser");
withHandler(
[
on(Print, (msg) => {
console.log("[outer print]", msg);
}),
],
() => {
withHandler(
[
on(GetUser, (id) => ({ id, name: `InnerUser-${id}` })),
],
() => {
const user = perform(GetUser, 42);
perform(Print, `Hello ${user.name}`);
}
);
}
);

Here GetUser is handled by the inner scope, while Print is not, so it bubbles outward to the outer scope.

The execution looks like this:

perform(GetUser, 42)
->
inner scope handles it
perform(Print, ...)
->
inner scope does not handle it
->
outer scope handles it

The output is:

[outer print] Hello InnerUser-42

This makes the scoping rules visible and shows why the stack model is useful.

Unhandled effects

The runtime should also make missing interpretations explicit:

const ReadConfig = createEffect<string, string>("ReadConfig");
function program() {
const value = perform(ReadConfig, "API_URL");
console.log(value);
}
program();

This produces:

Error: Unhandled effect: ReadConfig

That behavior is desirable. A missing interpretation should fail loudly rather than silently producing undefined behavior.

What this implementation gives us

Even though this runtime is small, it already demonstrates several important properties:

  • typed effect declarations
  • direct-style programming
  • dynamic handler scopes
  • nested handlers
  • bubbling to outer scopes
  • explicit unhandled-effect errors

That is enough to illustrate the separation between requesting an operation and interpreting it.

What it does not give us yet

This version is still not a full algebraic-effect system.

The reason is simple: handlers only receive a payload and return a result:

(payload) => result

They do not receive a continuation:

(payload, continuation) => ...

So they cannot yet:

  • resume later
  • resume more than once
  • discard and restart the continuation
  • directly manipulate the future of the computation

In that sense, this implementation gives us typed effect dispatch with dynamic handler scoping, but not yet true resumable algebraic effects.

That next step would require continuations, either through CPS transformation, generators, or another suspension mechanism.

Why this small implementation still matters

Even with that limitation, the exercise is valuable because it makes the central idea visible in code:

program expresses the operation
->
runtime finds a handler
->
handler gives the operation a meaning
->
program continues

That is the essence of the model.

And once this minimal version is clear, it becomes much easier to understand why full algebraic effects add continuations as the final step.

The next section turns to the other side of the story: the limits and trade-offs of this model.


Limitations and Challenges

Algebraic effects are elegant, but they are not free. They offer a cleaner way to organize effectful computation, yet that cleaner surface comes with real costs underneath.

A first challenge is implementation. A small dispatch-based effect system is easy to build, as the JavaScript and TypeScript examples showed. But full algebraic effects require handlers to interact with continuations. That means the runtime must be able to suspend a computation, preserve what remains, and later resume it in a controlled way. In practice, this usually requires continuation-passing style, generators, delimited continuations, compiler support, or some other transformation of execution. So the simple mental model hides a much harder runtime problem.

A second challenge is reasoning about control flow. Ordinary function calls are already familiar: a function runs, returns, and execution moves on. Once handlers can resume later, resume more than once, cancel the continuation, or redirect control elsewhere, the future of the computation becomes more subtle. The code may still look direct, but what actually happens at runtime may be more complex than the surface suggests.

That leads naturally to a third challenge: debugging. If a computation can be interrupted and resumed through handlers, the runtime path may be less obvious than the written code suggests. This is already familiar in asynchronous systems, where a direct-looking function may suspend and resume later. Algebraic effects generalize that pattern. Without strong tooling, stack traces, execution order, and error propagation may become harder to inspect.

A fourth challenge is performance. There is no universal rule here, because cost depends on the language, runtime, and implementation strategy. But effect handling is not automatically free. Installing handlers, capturing continuations, resuming computations, and layering interpretations around execution can all introduce overhead in time or memory. In some environments, that overhead is acceptable. In others, it becomes an important design constraint.

A fifth challenge is adoption. Some languages expose algebraic effects directly, but most mainstream ecosystems still rely on specialized constructs such as exceptions, promises, generators, framework-specific mechanisms, or library-level approximations. That means algebraic effects are still unevenly supported in practice. For many developers, they remain more familiar as a powerful concept than as an everyday built-in feature.

There is also a design challenge around readability. One of the strengths of algebraic effects is that they let code stay close to direct style. But that same strength can become a weakness if too much behavior is hidden in surrounding handlers. A function may look simple while depending on a rich and non-obvious handler environment. In those cases, the abstraction can hide too much, and the code may appear simpler than it really is.

Composition also still requires care. Algebraic effects are often praised for composing better than deeply stacked monadic structures, and there is truth in that. But better composition does not mean no composition problems. The ordering of handlers may matter. Different handlers may interact in subtle ways. A logging handler, retry handler, and transaction handler may not compose identically depending on how they are layered. So algebraic effects improve structure, but they do not eliminate design work.

A compact summary makes the trade-offs easier to see:

ChallengeWhy it matters
Runtime complexityFull effect handlers require continuation machinery
Control-flow subtletyHandlers can alter how computation resumes
Debugging difficultyExecution may be less obvious than surface syntax suggests
Performance costCapturing and resuming computation can add overhead
Uneven adoptionNative support is still limited in many ecosystems
Hidden behaviorDirect-looking code may depend on implicit handler context
Composition concernsMultiple handlers can still interact in nontrivial ways

The honest takeaway is simple:

algebraic effects do not remove complexity
->
they reorganize it

That reorganization is often extremely valuable. But it is still a trade, not magic.

The final section can now close the article by gathering the main thread: what algebraic effects are, why they matter, and what makes them different from the many mechanisms they help us understand.


Conclusion

Algebraic effects begin with a simple intuition: a computation reaches a point where it cannot continue alone, so it requests an operation, lets a handler interpret that request, and then continues.

That small idea turns out to explain a great deal.

Throughout this article, we started from familiar mechanisms: callbacks, promises, async/await, exceptions, generators, coroutines, and React Suspense, and saw that many of them can be understood as different ways of dealing with the same deeper problem: what should happen when a computation cannot proceed normally on its own?

Algebraic effects offer a general answer. They separate what the program asks for from how that request is interpreted, and once continuations enter the picture, they go further still: they let handlers influence not only the meaning of operations, but also the future of the computation itself.

That is why they matter:

  • They let code stay closer to the shape of the problem.
  • They provide a cleaner separation between logic and execution policy.
  • They make it easier to imagine the same computation running under different interpretations.
  • And they offer a more unified lens for understanding many control-flow mechanisms that are usually learned in isolation.

At the same time, they are not a miracle cure. They do not erase failure, eliminate runtime cost, or remove the need for good design. Full effect handlers are difficult to implement, continuations are subtle, and good tooling matters. Their power comes precisely from the fact that they operate close to the structure of computation itself.

If one compact formula should remain from the whole discussion, it is this:

perform
->
handle
->
continue

And if one sentence should remain, it is this:

Algebraic effects let a program express what it needs, while handlers decide what that need means and how computation should proceed afterward.

They do not remove complexity.

They give it a better place to live.


Discover more from Code, Craft & Community

Subscribe to get the latest posts sent to your email.

Leave a Reply

Discover more from Code, Craft & Community

Subscribe now to keep reading and get access to the full archive.

Continue reading