Effects & Handlers
An effect is a capability a function uses — logging, fetching from the network, error handling — without specifying how it's actually carried out. The function declares the effect it needs, and a handler, supplied by the caller, provides the implementation.
If you've worked with dependency injection in other languages, the basic shape will feel familiar:
- An effect is like an interface: a named set of operations.
- A handler is a concrete implementation of that interface.
- The caller of the operation chooses which implementation to use.
With effects, dependency injection is just a pattern — and the compiler checks the wiring for you. You decide which handlers to use at each function call, or even at your application's entry point, and every operation is guaranteed a handler before the program runs.
Handlers control the continuation
Effects go further than DI in one important way. When a function calls an effect operation like log!, control doesn't just call the handler — it hands the handler the code that would run next, as a continuation. The handler can decide what to do with it:
- Resume it once: behaves like a normal function call with a return value
- Don't resume it: the rest of the code never runs, like an early return or error handling
- Resume it after a delay: retries, backoff, supervision
- Resume it multiple times: backtracking, nondeterminism, generators
This is one of the features that makes effects a powerful abstraction. The same mechanism that allows you to swap behavior in and out also expresses error handling, concurrency, supervision, and even constraint solvers.
Why you'd reach for an effect
- Testing without mocks. Provide a different handler in tests; the function under test is unchanged.
- Per-environment swapping. Dev runs against SQLite, prod against
Postgres, with one handler choice at
main. - Ambient context. Per-request values like the current user, request ID, or feature flags can be set up once at the request boundary instead of threaded through every function argument. This subsumes the Reader monad and request-scoped DI containers — see Ambient context for a worked example.
- Non-local control flow. Errors that propagate without manual forwarding. Backtracking search. Guaranteed cleanup. Retries.
It's effects all the way down
Because effects are this useful, Saga leans on them everywhere. The standard library exposes the file system, console, HTTP, time, randomness, BEAM concurrency, and even the testing framework as effects, and ships with the handlers you'd expect for each. Once you understand the model in this section, you understand the shape of the whole stdlib.
This is why function signatures stay honest: any effect a function performs
but doesn't handle internally appears in its needs clause. You can see at
a glance which handlers it will require from its caller.
What you get
- Signatures that tell the truth. No hidden I/O, no surprise side effects.
- No monad transformers. Effects compose by listing them in
needs. No lifting between layers, no stack ordering. - Test isolation is just handler swapping. No mocking libraries, no patching imports.
- Patterns that need a dedicated library elsewhere are a few lines of user code here. Scoped resources, async/await, and supervision all fall out of the same handler machinery.
The rest of this section covers how to declare effects, write handlers, and wire them up. Error Handling, Handler Patterns, and Advanced Effects build on this foundation.
Declaring an effect
An effect is a named group of operations, defined with signatures but no implementation:
effect Log {
fun log : String -> Unit
}This says "there is an operation called log that takes a String and returns
Unit." It does not say what log actually does. That is the handler's job.
Effects can have multiple operations:
effect Http {
fun get : Request -> Response
fun post : Request -> Response
}Performing effects
Effect operations are called with ! at the call site:
log! "server starting"
let resp = get! health_checkThe ! marks the exact point where control may transfer to a handler. Pure
function calls never get it. If you see a !, you know something beyond
ordinary computation is happening.
Only operations declared directly in an effect block use !. Calling a
function that internally uses effects is a normal call:
# log! uses the bang because it is an effect operation
# process_request does not, even though it uses effects internally
fun process_request : Request -> Response needs {Log, Http}
process_request req = {
log! "handling request"
let data = get! req.url
parse_response data
}
# Calling process_request is a regular call
process_request my_requestThe needs clause
A function that performs effects without handling them itself must declare them
with needs:
fun greet : String -> Unit needs {Log}
greet name = log! $"Hello, {name}!"This tells callers (and the compiler) what handlers must be provided. If a function calls another function that needs an effect without handling it, that effect propagates to the caller's signature:
# greet needs {Log}, and we don't handle it here, so run also needs {Log}
fun run : Unit -> Unit needs {Log}
run () = greet "world"Effects propagate all the way up the call chain until someone provides a
handler. In some instances, you may want effects to bubble all the way up to your main function, where you wire in their handlers:
fun main : Unit -> Unit
main () = {
run_app ()
} with {
console,
http,
postgres,
to_result,
}Writing a handler
A handler provides implementations for an effect's operations. The simplest form is a named handler declaration:
handler console for Log needs {Stdio} {
log msg = {
print! msg
resume ()
}
}Three things to notice:
- The handler specifies which effect it implements with
for Log. - Each arm implements one operation. The arm for
logreceivesmsg(the argument from the call site) and decides what to do with it. - This handler
needs {Stdio}because its implementation callsprint!. Handlers can require effects of their own, covered in more detail below.
resume: continuing the computation
resume is a keyword available inside any handler arm. It sends a value back
to the point where the effect was performed, and the computation continues from
there.
In the console handler above, resume () means: "the call to log! returns
Unit, and the code after log! keeps running."
Here is a more illustrative example:
effect Env {
fun get_env : String -> Maybe String
}
handler dev_env for Env {
get_env "DATABASE_URL" = resume (Just "localhost:5432/dev")
get_env "APP_SECRET" = resume (Just "dev-secret")
get_env _ = resume Nothing
}
fun db_url : Unit -> String needs {Env}
db_url () = case (get_env! "DATABASE_URL") {
Just url -> url
Nothing -> "localhost:5432/default"
}When db_url calls get_env! "DATABASE_URL", control transfers to the
dev_env handler. The handler pattern-matches on the key, and calls
resume (Just "localhost:5432/dev"). At that point, get_env! returns
Just "localhost:5432/dev" and db_url continues into the case.
Not resuming: aborting the computation
A handler does not have to call resume. If it doesn't, the computation is
abandoned and the handler's return value becomes the result of the entire with
block.
The built-in Fail effect is the clearest example. It declares a single
operation that never returns normally:
effect Fail {
fun fail : String -> a
}
fun safe_divide : Int -> Int -> Int needs {Fail}
safe_divide x 0 = fail! "division by zero"
safe_divide x y = x / y
let result = {
safe_divide 10 0
} with {
fail reason = Err reason
}
# result is Err "division by zero"When fail! is performed, the handler returns Err reason directly. Everything
after fail! in the computation is skipped. This is how error handling works in
Saga: there is no special syntax for exceptions or try/catch. Fail is just an
effect, and aborting is just a handler that chooses not to resume.
The return clause
When a handler aborts, the failure path produces a value (like Err reason
above). But what about the success path? By default, the success value passes
through unchanged. A return clause lets the handler intercept it:
handler to_result for Fail {
fail reason = Err reason
return value = Ok value
}Now both paths return the same type:
safe_divide 10 0 with to_result # Err "division by zero"
safe_divide 10 2 with to_result # Ok 5Note that return here is not the keyword you may know from other languages.
Saga has no general return statement. In this context, return is a special
clause in a handler that transforms the final value of a successful computation.
It only appears inside handler definitions.
Most handlers don't need it. It comes up when both the success and failure paths
need to produce a unified type, as with to_result. More advanced uses of
return are covered in Handler Patterns.
Attaching handlers with with
The with keyword connects a computation to its handler:
# Single handler
greet "world" with console
# Multiple handlers in a block
fun main : Unit -> Unit
main () = {
process_request my_request
} with {
console,
http,
}The handler can be a named declaration, a local binding, or even defined inline:
# Inline handler for a one-off case
greet "world" with {
log msg = {
print! $"[LOG] {msg}"
resume ()
}
}Everything between the opening { and the } with is the "handled
computation." Any effect operations performed inside that block (or in functions
called from it) are routed to the attached handlers.
Handlers that use effects
The dev_env handler above is pure: it returns hardcoded values and needs no
effects of its own. But a production handler might need to reach out to a
secrets manager. Declare those dependencies with needs on the handler:
effect SecretStore {
fun fetch_secret : String -> Maybe String
}
handler prod_env for Env needs {SecretStore} {
get_env key = {
let secret = fetch_secret! key
resume secret
}
}This handler implements Env by delegating to a SecretStore effect. When you
attach it, SecretStore must also be handled somewhere in the chain:
main () = {
run_app ()
} with {
prod_env,
onepassword,
}A handler with no needs clause is pure, like dev_env from earlier.
Effects compose naturally
When a function needs multiple effects, it just lists them. There is no layering, no ordering to get right, and no special plumbing to combine them.
If you've used monad transformers in other languages (building stacks like
ReaderT Config (ExceptT Error (StateT S IO)) with lifting between layers),
this is the problem effects eliminate entirely:
fun process : Request -> Response needs {Log, Http, Fail, Database}
process req = {
log! "start"
let user = query! req.user_id
let data = get! user.api_url
if data == "" then fail! "empty response"
else build_response data
}Inside the function body, there is no layering. You call log!, query!,
get!, and fail! freely without worrying about which "layer" each belongs to.
There is no lifting between effect layers.
main () = {
process my_request
} with {
console,
http,
to_result,
postgres,
}Adding a new effect to a function is just adding it to needs and providing a
handler. No restructuring, no type-level plumbing.
A function with fewer effects always fits where more are allowed. If a caller
expects needs {Log, Http}, a function that only needs {Log} works fine. A
pure function (no needs at all) fits everywhere.
Testing with effects
Since handlers are swappable, testing is straightforward: use the same function with different handlers. No mock frameworks, no patching imports.
handler mock_http for Http {
get req = resume (Response 200 "{\"status\": \"ok\"}")
post req = resume (Response 201 "created")
}
handler silent for Log {
log msg = resume ()
}
test "process_request returns valid response" (fun () -> {
let resp = process_request test_req with {
mock_http,
silent,
}
assert_eq resp.status 200
})The function under test is unchanged. Only the handlers differ.
Putting it all together
Here is a complete example: declaring effects, writing functions that use them, implementing handlers, and wiring everything up:
# Declare effects
effect Log {
fun log : String -> Unit
}
effect Fail {
fun fail : String -> a
}
# Functions that use effects
fun parse_age : String -> Int needs {Fail}
parse_age input = case (String.to_int input) {
Ok n -> n
Err _ -> fail! $"not a number: {input}"
}
fun validate_age : Int -> Int needs {Fail}
validate_age age =
if age < 0 || age > 150
then fail! $"age out of range: {age}"
else age
fun process_input : String -> String needs {Log, Fail}
process_input input = {
log! $"processing: {input}"
let age = parse_age input
let valid = validate_age age
$"valid age: {valid}"
}
# Handlers
handler console for Log needs {Stdio} {
log msg = {
print! msg
resume ()
}
}
handler to_result for Fail {
fail reason = Err reason
return value = Ok value
}
# Wire it up
fun main : Unit -> Unit
main () = {
let result = process_input "25" with to_result
case result {
Ok msg -> print! msg
Err err -> print! $"Error: {err}"
}
} with {console, stdio}The next section covers all the ways handlers can be defined, composed, and combined.