Effects
Partial — perform,
handle, and closed latent effect sets on lambdas work today. Applying
an unhandled effectful function is E0202. Handlers are shallow in-scope
dispatch (resume is an identity continuation, not a captured continuation).
Open rows and standalone effect declarations are not implemented yet.
Algebraic effects separate what a computation requests from how the
request is served. A function says perform Log.log msg; the caller
decides whether that means a file append, a network call, or nothing at all.
The same function works in production, in tests, and in a REPL without
changing a line.
perform and handle
Section titled “perform and handle”perform Effect.op arg requests operation Effect.op from the nearest
enclosing handle that covers it. A handler clause receives the operation’s
arguments plus a resume continuation; calling resume value returns
value to the perform site and continues the handled expression:
handle let x = perform State.get () in x + 1with State.get () resume -> resume 41 return val -> valend# => 42Reading the example: the body performs State.get (). The handler’s
State.get clause answers with resume 41, so the perform expression
evaluates to 41, the body continues, and produces 42. The return
clause then transforms the body’s normal result — here it is the identity.
The general shape is:
handle <body> with Effect.op args resume -> <clause body> ... return val -> <return body>end- Operations are identified by their
Effect.oplabel at theperformsite. There is no separate declaration step today —effect ... enddeclaration blocks are planned but do not parse yet. - The
return val -> …clause runs on the body’s normal result (not on eachresume). It is optional and defaults to identity. - Nested handlers shadow outer ones for the operations they cover: the
innermost covering
handlewins.
handle handle perform State.get () with State.get () resume -> resume 99 return val -> val endwith State.get () resume -> resume 0 return val -> valend# => 99 (the inner handler answers)Effect rows on function types
Section titled “Effect rows on function types”Function arrows carry a latent effect row: the operations the function
may perform, plus an optional open rest. The compiler infers this from
perform and from applying other effectful functions. You never write the
row in source; it appears in diagnostics as !{Effect.op}:
\x -> perform State.get () # a -> !{State.get} b\f x -> f x # (a -> !{|r} b) -> a -> !{|r} bWhat is implemented today:
performinside a lambda, when no coveringhandleis in scope, is recorded on that function’s row.- Application unions the callee’s uncovered ops (and open rest) into
the enclosing function — so
let apply = \f x -> f xandlet g = \() -> f ()propagate effects. handlesubtracts the operations its clauses cover. Ops not covered escape into the enclosing row.handlecovers apply. Applying the function inside a handler for those operations is accepted — includinglet f = \x -> perform E.op x in handle f v with ….- Application of an uncovered row is E0202 (below).
- Direct
performwith no enclosing lambda and no handler stays a-Wunhandled-effectwarning.
Closed and open HOF rows on exported FN lines are restored on import.
A least-fixed-point story beyond generalizing the rest var is not
implemented.
E0202 — unhandled effects are errors
Section titled “E0202 — unhandled effects are errors”Applying a function whose row is not fully covered by any surrounding
handler is a compile-time error (E0202), not a warning. The primary
diagnostic points at the introducing perform, with a note at the call
that let the effect escape:
let f = \x -> perform State.get () in # f : a -> !{State.get} Intf 0# error[E0202]: unhandled effect State.get# --> points at `perform State.get ()`# note: applied here with no covering handlerThe fix is a handler at the use site:
let f = \x -> perform State.get () inhandle f 0 with State.get () resume -> resume 7 return val -> valend# => 7A direct perform with no handler in scope (not mediated through a
function application) compiles with a -Wunhandled-effect warning and
raises :UnhandledEffect at runtime if reached.
Rows cross module boundaries
Section titled “Rows cross module boundaries”.yonai FN lines may carry a closed set, an open rest, or both:
FN yona_Test_Fx__fetch 1 STRING -> STRING effects Fs.readFN yona_Test_Hof__apply 2 STRING -> STRING effects | hofeffects | hof is the apply f x = f x shape: the first parameter is
a function, and applying it propagates that argument’s effects. A
missing effects field stays unknown (fresh type vars), so existing
stdlib interfaces are unchanged. Module compile typechecks siblings as
a unit, so wrap = \() -> readSecret () records readSecret’s row on
wrap. Importing and applying an effectful export is E0202 unless
a handle at the import/apply site covers every listed op.
Worked example: GPU fallback
Section titled “Worked example: GPU fallback”Std\GPU uses effects to let you decide what a GPU failure means.
Kernels report issues as ordinary values; raiseGpu converts an issue into
a perform Gpu.*, designed to be answered by a handler at your call site:
import raiseGpu, GpuOom from Std\GPU inhandle raiseGpu GpuOom # performs Gpu.oom ()with Gpu.oom () resume -> resume () # e.g. log and fall back to CPU Gpu.deviceLost () resume -> resume () Gpu.fail code resume -> resume () return val -> 1end# => 1Step by step: raiseGpu GpuOom performs Gpu.oom (); the handler resumes
with (), so the handled expression yields (); the return clause maps
that to 1. A different caller could resume differently — retry, abort, or
switch backends — without touching the kernel code.
withGpuFallback action wraps this pattern: it runs action, then performs
the matching Gpu.* operation for the last classified issue (no-op on
success).
Current limitations
Section titled “Current limitations”Stated plainly, because they shape what you can write today:
- Handlers are shallow, in-scope dispatch.
resumeis an identity continuation: the clause computes a value and execution continues at theperformsite. You cannot captureresume, call it later, call it twice, or decline to call it to abort with a different value — patterns like backtracking, generators-via-effects, and resumable exceptions that need a first-class delimited continuation are not expressible yet. effectdeclarations do not parse. Operations exist only asEffect.oplabels atperformandhandlesites; there is no place to declare operation signatures, so argument types are checked structurally at each site.- A missing
effectsfield means unknown, not pure. Closed sets andeffects | hofare restored;\x f -> f x(function not first) is not a serialized HOF shape.
Why effects
Section titled “Why effects”Compared with the usual alternatives:
- Versus exceptions: a handler can
resume, so the computation continues after the operation — exceptions can only unwind. - Versus dependency injection: no interfaces, containers, or mock frameworks; the handler is the injected behavior, scoped lexically.
- Versus monads: effectful code is direct style — no transformer stacks, no lifting, and pure code pays nothing.
Testing falls out for free — handle the effect with canned data:
let getUsers = \() -> perform Db.query "SELECT * FROM users" inhandle getUsers () with Db.query sql resume -> resume [("alice", 30), ("bob", 25)] return val -> valend# => [("alice", 30), ("bob", 25)] (no database anywhere)See the specification for the formal typing
rules, and Concurrency for the built-in
Cancel.check effect used by cooperative cancellation.