Handler Patterns
So far, handlers have been either named declarations or inline blocks attached
with with. Handlers are more flexible than that: they are values you can bind,
pass around, build from configuration, and compose together.
Handler Bindings
Handlers are values. You can bind them to variables with let, which means you
can select a handler at runtime:
fun main : Unit -> Unit
main () = {
let logger = if env == "dev" then console else sentry
let db = if env == "dev" then sqlite else postgres
run_app () with { logger, db }
}Both branches must produce the same handler type (Handler Log in this case),
but the choice happens at runtime. The computation itself is written once.
Handler Factories
A handler expression (handler for Effect { ... }) produces a Handler Effect
value without giving it a name. This is useful for factory functions that build
handlers from configuration:
fun make_logger : String -> Handler Log needs {Stdio}
make_logger prefix = handler for Log {
log msg = {
print! $"[{prefix}] {msg}"
resume ()
}
}
fun main : Unit -> Unit
main () = {
let logger = make_logger "app"
run_app () with { logger, stdio }
}The factory closes over prefix, so every log! call in the handled
computation gets the configured prefix.
Bundling Multiple Effects
A single handler can implement multiple effects. This is useful for grouping related configuration into one unit:
handler dev for Log, Database needs {Stdio, Sqlite} {
log msg = {
print! msg
resume ()
}
query sql = sqlite_query! sql |> resume
}
handler prod for Log, Database needs {Sentry, Postgres} {
log msg = {
sentry_send! level msg
resume ()
}
query sql = postgres_query! sql |> resume
}
fun main : Unit -> Unit
main () = {
let env_handler = if env == "production" then prod else dev
run_app () with env_handler
}Stacking Handlers
When a computation needs multiple effects, attach multiple handlers in a with
block. Named handlers, bindings, and inline arms can be mixed freely:
main () = {
run_app ()
} with {
console,
postgres,
get req = http_get! req |> resume,
}For a single handler, the braces are optional:
greet "world" with consoleOrdering
A with block is syntactic sugar for nested with expressions. The first
handler listed is innermost:
expr with { a, b, c }
# is equivalent to:
{ { expr with a } with b } with cWhen an effect operation is performed, the nearest enclosing handler gets the
first chance to handle it. If that handler does not define the operation, it
propagates outward to the next one. So if a and b both define log, a
handles it because it is innermost.
The return Clause
As introduced in the previous section, return intercepts the success value of
a handled computation. The most common use is to_result:
handler to_result for Fail {
fail reason = Err reason
return value = Ok value
}Without return, a successful computation passes its value through unchanged.
With return, the handler transforms it.
return chains through stacking
When multiple stacked handlers define return clauses, they compose through the
nesting order. Given expr with { a, b } (which desugars to
{ expr with a } with b):
- The computation produces a value
a.returntransforms itb.returntransforms the result of step 2
This means each return clause sees the output of the previous one, not the
original value.
Handlers Close Over Scope
Handlers are closures. They can reference variables from the surrounding scope:
fun main : Unit -> Unit
main () = {
let conn = connect! config
let debug = True
run_app ()
} with {
query sql = {
if debug then log! sql else ()
execute! conn sql |> resume
},
}The inline handler captures conn and debug from the enclosing block. Named
handlers close over the scope where they are defined (typically module scope).
Handler factories, as shown above, close over their arguments.
Re-entrant Effects (Middleware)
A handler can re-perform the same effect it handles by declaring it in its own
needs. The re-performed operation routes to an outer handler, not back to
itself. This enables middleware: intercept an effect, delegate to the real
implementation, and add behavior around it.
handler with_retry for Http needs {Http} {
get req = {
let resp = get! req
if resp.status == 500 then get! req
else resume resp
}
}
# Stack it: with_retry wraps http
{ fetch_data () with with_retry } with httpWhen with_retry calls get! req, its needs {Http} is satisfied by the outer
http handler. The result flows back to with_retry, which decides
whether to retry or resume.
This pattern works for any effect: logging wrappers, caching layers, metrics, retry logic. The shape is always the same: handle the effect, re-perform it to delegate, wrap the result.
Cleanup with finally
A handler arm can include a finally block that runs after resume's
continuation completes, regardless of whether the computation succeeded or was
aborted by another handler. This guarantees resource cleanup:
effect Resource {
fun acquire : String -> String
}
handler managed for Resource {
acquire name = {
let handle = open_resource name
resume handle
} finally {
close_resource handle
}
}The finally block runs after everything that follows resume has finished. If
the computation completes normally, finally runs. If another handler (like
to_result) aborts the computation, finally still runs:
fun work : Unit -> String needs {Resource, Fail}
work () = {
let db = acquire! "db"
fail! "something went wrong"
}
# The managed handler's finally block runs even though
# to_result aborts the computation
let result = {
work () with managed
} with to_result
# db resource is cleaned up, result is Err "something went wrong"The finally block inherits the scope of its handler arm, so variables bound
before resume (like handle above) are accessible in the cleanup code.
One important constraint: effects performed inside a finally block cannot
propagate beyond the handler they belong to. The block can use effects that are
already available inside the handler (including those declared in its own
needs), but it cannot introduce new unhandled effects. This ensures cleanup
code is self-contained and cannot itself be interrupted by an outer handler.