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_resultThe 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 consolerun_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 idCalling 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 restrender_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 postgresCleanup 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] BThe 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.
Backtracking search
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.