SagaSaga
Guide

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 console

Ordering

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 c

When 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):

  1. The computation produces a value
  2. a.return transforms it
  3. b.return transforms 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 http

When 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.