SagaSaga
Guide

Advanced Effects

The previous sections covered declaring effects, writing handlers, and composing them. This section goes deeper into how effects interact with higher-order functions, and what else you can do with resume.

Open vs Closed Effect Rows

When a higher-order function takes a callback, the type annotation controls which effects that callback is allowed to use.

A closed row lists exactly the effects allowed. The higher-order function is expected to handle them internally:

fun try : (Unit -> a needs {Fail}) -> Result a String
try f = f () with to_result

The callback can use Fail and nothing else. try handles it, and no effects leak out. A callback with no needs clause at all is the most closed: it must be pure.

An open row uses ..e to accept additional effects and forward them to the caller:

fun run_logged : (Unit -> a needs {Log, ..e}) -> a needs {..e}
run_logged f = f () with console

run_logged handles Log itself but forwards everything else through ..e. If the callback also uses Fail, then ..e includes Fail and the effect propagates upward through run_logged's signature:

# callback uses Log and Fail
# run_logged handles Log, Fail propagates through ..e
fun main : Unit -> Unit needs {Fail}
main () = run_logged (fun () -> {
  log! "about to do something risky"
  fail! "oops"
})

This is how the standard library works. List.map and List.filter take pure callbacks (closed, no effects). List.iter takes an open row because it exists to run a function for its effects:

pub fun iter : (a -> Unit needs {..e}) -> List a -> Unit needs {..e}

When ..e is empty (a pure callback), iter has no effects. When ..e is {Log}, iter needs {Log}. The row variable captures whatever the callback actually does.

Combining Effects and Traits

A function can require both effects and trait bounds. In the signature, needs comes first and where comes second:

pub fun print_all : List a -> Unit needs {Log} where {a: Show}
print_all items = case items {
  [] -> ()
  h :: t -> {
    log! (show h)
    print_all t
  }
}

Read it as: "this function needs these effects" (runtime), "where these types satisfy these traits" (compile-time).

In that example the function performs Log itself and the trait bound (Show) is pure. The next section covers the other case: a trait method that is itself effectful.

Effectful Trait Methods

Trait methods are pure by default. Show, Eq, and Ord declare no effects, and that purity is enforced — but the rule is about what escapes an impl, not what it does internally. An impl may perform any effect operation; what it cannot do is leave an effect unhandled unless the trait method's row permits it. So a pure method means every effect an impl performs must be handled within the impl itself.

trait Show a {
  fun show : a -> String
}

# Fine: log! is performed, but handled inside the impl, so nothing escapes.
# show stays pure.
impl Show for User {
  show user = {
    log! "rendering a user"
    user.name
  } with { log _ = resume () }
}

# Error: log! escapes unhandled, and show's pure row leaves no room for
# Log to propagate out to the caller.
impl Show for User {
  show user = {
    log! "rendering a user"
    user.name
  }
}

This is the open-row forwarding rule from earlier, applied to traits: an impl can only let effects escape that the trait method's signature names or opens — anything else it must handle itself. Keeping it opt-in is what lets generic code over Show/Eq stay clean — a function where {a: Show} never picks up surprise effects, because no impl can leak one.

Declaring the capability

To let impls perform effects, give the method an effect row. Just like a callback, it can be closed (naming exactly which effects are allowed) or open (..e, allowing any).

A closed row names the effects every impl may use:

trait Render a {
  fun render : a -> String needs {Log}
}

impl Render for User needs {Log} {
  render user = {
    log! $"rendering {user.id}"
    user.name
  }
}

Because those effects are part of the method's declared type, they propagate like any other named effect — calling render requires Log whether the call is on a concrete type or behind a generic bound.

An open row lets each impl choose its own effects, filling in ..e:

trait Render a {
  fun render : a -> String needs {..e}
}

# This impl fills ..e with {Database}
impl Render for ProductId needs {Database} {
  render id = query! $"SELECT name FROM products WHERE id = {id}"
}

# A pure impl fills ..e with nothing
impl Render for Int {
  render n = show n
}

Effects flow from the impl to the caller

When you call an effectful trait method on a concrete type, the selected impl's effects become part of your function's effects:

# render on a ProductId needs {Database}, so describe does too
fun describe : ProductId -> String needs {Database}
describe id = "product: " <> render id

Calling render on an Int adds nothing, because that impl is pure. The effects you pick up are exactly the ones the chosen impl actually performs.

Forwarding through a generic function

Inside a generic function the self type is abstract, so the compiler can't know which impl will be chosen, nor which effects render will perform. It can't handle effects it can't name, so it must forward them. The effects contributed by a constraint surface as a row variable named after the type variable — ..a reads as "a's effects":

fun render_all : List a -> String needs {..a} where {a: Render}
render_all [] = ""
render_all (x :: rest) = render x <> "\n" <> render_all rest

render_all works for any a that implements Render. Instantiated at ProductId, ..a resolves to {Database} and the caller needs a Database handler; at Int it resolves to nothing and render_all is pure. One function, effects determined per instantiation.

A function bound over two effectful traits forwards one row variable per constraint:

fun report : a -> b -> String needs {..a, ..b} where {a: Render, b: Render}

A pub function must declare these rows in its signature; a private function may leave them to be inferred.

This explicitness is deliberate. A generic function's effects are fixed by the trait bounds written at its definition, never by which impls happen to exist elsewhere — so adding a new effectful impl in another module can never silently change render_all's type. It is the same reason effect-capability is opt-in on the method in the first place: effects you didn't ask for never appear.

Scoped Resources

The finally keyword from Handler Patterns enables a pattern for flat, multi-resource management. Some libraries (like Effect-TS) need a dedicated Scope API for this. In Saga, it falls out of the existing handler machinery in a few lines:

effect Scope {
  fun acquire_scoped : (acquire: Unit -> a) -> (release: a -> Unit) -> a
}

handler run_scoped for Scope {
  acquire_scoped acquire release = {
    let resource = acquire ()
    resume resource
  } finally {
    release resource
  }
}

acquire_scoped! takes two functions: one to acquire a resource and one to release it. The handler acquires the resource, passes it to the computation via resume, and the finally block guarantees cleanup runs when the computation exits, in reverse acquisition order.

{
  let db = acquire_scoped! (fun () -> connect "postgres") disconnect
  let cache = acquire_scoped! (fun () -> connect "redis") disconnect
  query db
  lookup cache
} with run_scoped
# redis disconnected first, then postgres

Cleanup runs regardless of how the computation exits: normal completion, abort via Fail, or panic. And because each resource registers its own cleanup at acquisition time, you can acquire multiple resources without nesting:

let result = {
  let db = acquire_scoped! (fun () -> connect "postgres") disconnect
  let cache = acquire_scoped! (fun () -> connect "redis") disconnect
  query db
  fail! "something broke"
} with { run_scoped, to_result }
# both resources cleaned up, result is Err "something broke"

Multishot Continuations

In every handler example so far, resume has been called at most once. But a handler can call resume multiple times. Each call re-runs the entire remaining computation from the effect call site, branching the execution.

Here is a simple example: a Log handler that runs the continuation twice for every log! call:

handler double_log for Log {
  log msg = {
    dbg ("[1] " <> msg)
    resume ()
    dbg ("[2] " <> msg)
    resume ()
  }
}

fun do_work : Unit -> Unit needs {Log}
do_work () = {
  log! "A"
  log! "B"
}

main () = do_work () with double_log
# [1] A
# [1] B
# [2] B
# [2] A
# [1] B
# [2] B

The first resume runs the rest of the computation (which hits log! "B" and branches again). The second resume re-runs from the same point, producing a tree of executions.

Multishot continuations become genuinely useful for nondeterministic search. A Choose effect lets you write search problems as straight-line code, and the handler silently explores all branches:

effect Choose {
  fun choose : List a -> a
}

handler all_solutions for Choose, Fail {
  choose options = List.flat_map (fun x -> resume x) options
  fail _ = []
  return value = [value]
}

The handler calls resume once per option in the list, collecting all results with flat_map. Branches that hit fail! produce an empty list and are pruned.

This lets you write a constraint solver that reads like imperative code:

fun guard : Bool -> Unit needs {Fail}
guard True  = ()
guard False = fail! "pruned"

fun pythagorean_triples : Int -> List (Int, Int, Int)
pythagorean_triples n = {
  let nums = List.range 1 n

  let a = choose! nums
  let b = choose! (List.range a n)
  let c = choose! (List.range b n)

  guard (a * a + b * b == c * c)

  (a, b, c)
} with all_solutions

pythagorean_triples 20
# [(3, 4, 5), (5, 12, 13), (6, 8, 10), (8, 15, 17), (9, 12, 15), (12, 16, 20)]

Each choose! looks like it picks one value, but the handler tries every possibility. guard prunes branches that don't satisfy the constraint. The result is all solutions collected into a list.

This is the full power of algebraic effects: the same resume mechanism that handles logging and error recovery also expresses nondeterministic search, all without special language support.