Understanding Effects

How algebraic effects unify IO, errors, and state.

The Problem

Side effects break two things programmers care deeply about: composition and testing.

Think about it concretely. A function that reads from the network cannot be composed cleanly with a function that reads from a file, because both need to know about IO. A function that throws an exception cannot be composed with one that returns an error value, because the caller has to juggle two different error conventions. A function that mutates global state cannot be tested reliably, because you have to reset that state between runs and hope nothing leaks.

The root cause is always the same: the function does something beyond computing a return value, and the language gives you no structured way to describe, intercept, or replace that behavior.

Traditional Solutions

Exceptions

Exceptions let you signal errors without threading return values through your code, which is convenient. But they are invisible in the type system (in most languages), they break control flow in surprising ways, and they cannot be resumed. When a function throws, it is done. The caller can catch the exception, but cannot tell the function to try a different approach.

Monads

Haskell's approach is to wrap effects in types like IO, State, and Either. This is principled and the type system tracks what effects you use. But it comes with real syntactic overhead: do-notation, monad transformers, and the genuine difficulty of combining multiple effects. Monad transformer stacks are a recurring source of frustration, even for experienced Haskell programmers.

Async/await

Async/await solves one specific effect, concurrency, but it creates a "function color" problem. Async functions cannot be called from sync functions without ceremony. Every function in the call chain must decide whether it is async, and changing that decision later ripples through the entire codebase. You end up with two parallel worlds of functions that do not mix easily.

The Effect Solution

Algebraic effects solve all of these problems with a single mechanism. The idea has three parts.

Declare

You define an effect as a set of operations. This is like declaring an interface, but for side effects:

[effect Console
  [fn readline [prompt] -> Str]
  [fn writeline [text] -> Unit]]

Perform

Code that needs the effect simply calls its operations. No special imports, no wrapping in monadic types, no ceremony at all:

[fn greet []
  [let name [perform Console.readline "Name: "]]
  [perform Console.writeline [str "Hello, " name "!"]]]

Handle

A handler intercepts effect operations and decides what to do with them. This is where things get powerful:

[handle [greet]
  [Console.readline prompt] [resume "World"]
  [Console.writeline text]  [do
    [println text]
    [resume]]]

The handler sees each operation as it happens and provides an implementation. The code that performed the effect does not know or care which handler is installed. It just asks for a readline and gets back a string.

How Resume Works

Here is the key feature that separates algebraic effects from exceptions: resume. When a handler calls resume, it sends a value back to the exact point where the effect was performed, and the function continues executing from there.

[fn ask-age []
  [let input [perform Console.readline "Age: "]]
  [parse-int input]]

; Handler that provides a fake input for testing
[handle [ask-age]
  [Console.readline _] [resume "25"]]

When ask-age performs Console.readline, control transfers to the handler. The handler calls [resume "25"], which sends "25" back to ask-age as the return value of perform. The function continues as if a real console had returned that string.

This is something exceptions fundamentally cannot do. An exception unwinds the stack and destroys the context. An effect handler pauses the computation, provides a value, and lets it keep running. That difference is everything.

This is what makes effects testable. You replace real IO with a handler that provides canned responses. The code under test does not change at all.

The Unification

The real power of effects is that IO, errors, state, and concurrency are all just effects. There is no special syntax for any of them. Once you understand the perform/handle pattern, you understand all of them.

IO as an effect

File reads, network calls, and console interaction are effects. Your code performs them; a handler at the program's boundary provides the real implementation. In tests, a different handler provides fake responses.

[effect Http
  [fn get [url] -> Result Str HttpError]]

Errors as an effect

Instead of throwing exceptions or returning Result everywhere, you can perform an error effect. The handler then decides whether to retry the operation, log the error and return a default, or propagate the failure:

[effect Fail
  [fn fail [err] -> Nothing]]

[handle [parse-config path]
  [Fail.fail err] [do
    [log-error err]
    default-config]]

State as an effect

You can model mutable state without actual mutation. The handler threads the state value through resume calls, so from the code's perspective it looks like mutable state, but underneath it is purely functional:

[effect State
  [fn get [] -> a]
  [fn put [val] -> Unit]]

Async as an effect

This is perhaps the most elegant application. There is no function coloring in Loon because concurrency is just another effect. A function does not need to declare itself async. It performs an async operation, and the runtime handler manages scheduling. Sync and async code compose freely.

Research Lineage

Algebraic effects are not a Loon invention. They come from a line of programming language research going back to Plotkin and Power (2003). The idea has been explored by several languages, each contributing different insights:

  • Eff, the original research language for algebraic effects.
  • Koka, which introduced evidence-based compilation, by Daan Leijen at Microsoft Research.
  • Frank, which explored effects as an extension of call-by-push-value.
  • OCaml 5, which brought shallow effect handlers to a mainstream language.

Loon builds on this body of work with a focus on ergonomics: full inference of effect types (so you never annotate them), concise syntax, and tight integration with ownership semantics.