This guide explains Turmeric's algebraic effects system and when to use it.
Turmeric implements algebraic effect handlers inspired by OCaml 5, enabling ergonomic asynchronous programming, custom control flow, and composable abstractions. Effects allow you to perform operations that handlers can intercept and re-route dynamically.
Three core primitives:
(defeffect Name [param :type ...] :return-type) -- Declares a new effect Name.(perform (Name arg ...)) -- Raises the effect, searching the dynamic handler stack for a matching handler.(handle expr (Name [p ...] k) body ...) -- Installs a handler. When the effect fires, k is the captured continuation; call (resume k value) to continue.;; Declare an effect
(defeffect Read [] :str)
;; Perform the effect
(def name (perform (Read)))
;; Handle the effect
(handle
(let [name (perform (Read))]
(println (str "Hello " name)))
(Read [] k) (resume k "World"))
Continuations in Turmeric are one-shot: calling (resume k v) consumes k, preventing reuse. This matches Turmeric's ownership model. (See Logic Programming Guide for cloneable continuations.)
k to a helperA handler case may hand its continuation to another function. Declare the
parameter effect-cont<T>, where T is what the handle delivers. A resume
there ((resume k v) or (k v)) yields that T. A resume is always typed as
what its handle delivers, not as the value it resumes with.
(defeffect Ask [] int)
(defn answer [k : effect-cont<int> v : int] : int (resume k v))
(defn step [i : int] : int
(handle (+ i (perform (Ask)))
(Ask [] k) (answer k 10))) ; 7 -> 17
effect-cont works too: the resume is then typed int.effect-cont parameter may resume a ^multishot case's k more than
once.multishot-effect-cont takes only the k of a case whose effect carries a
(fn [effect-cont] R) payload.cont cannot take a handler's k: it runs a different runtime.(discontinue k payload) resumes k by raising a panic at the perform
instead of answering it. It is in beta behind --enable=discontinue.
The raise starts inside the body, so the body's defers run as it unwinds,
and a catch-unwind around the perform can catch it and carry on:
(defeffect Ask [] : int)
(defn body [] : int
(defer (println "body: cleanup"))
(let [r (catch-unwind (fn [] : int (+ 1 (perform (Ask)))))]
(if (err? r) 100 200)))
(defn main [] : int
(println (handle (body) (Ask [] k) (discontinue k "no answer"))) ; cleanup, then 100
0)
discontinue, in the clause, and is an ordinary panic from there. A
catch-unwind around the handle takes it.discontinue returns what resume would: the handle's value, when the body
recovered. A cstr payload is the panic message. Any other payload is typed,
as with panic-with, and catch-panic-of filters on it.k like a resume, and counts as the one use of a ^linear k.catch-unwind in the clause around the discontinue catches it there,
and the copies of a ^multishot k can be resumed and discontinued
independently, each with its own defers and catches. Compiled, tur jit
and tur --interpret agree on every shape. See
discontinue-plan.handle-shallowA handle is deep: it stays installed around the continuation, so every
perform the body makes -- before or after a resume -- reaches the same clause.
handle-shallow takes the same arguments, but the handler answers one
perform and is gone: the continuation k the clause resumes runs without it,
so the body's next perform of that effect reaches an enclosing handler (or
aborts as unhandled). try-with is another spelling of handle -- deep, same
rules -- for readers who know it from OCaml, and for async code where it reads
as an exception handler (async-await-guide).
(defeffect Ask [] : int)
(defn two-asks [] : int
(+ (perform (Ask)) (* 10 (perform (Ask)))))
(defn main [] : int
;; deep: one clause answers both performs => 1 + 10*1
(println (handle (two-asks) (Ask [] k) (resume k 1))) ; 11
;; shallow: it answers the first; the second reaches the outer handle
(println (handle (handle-shallow (two-asks) (Ask [] k) (resume k 1))
(Ask [] k) (resume k 2))) ; 21
(println (try-with (two-asks) (Ask [] k) (resume k 4))) ; 44
0)
Because the body can still perform the effect after a shallow handler has gone,
handle-shallow does not remove the effect from the enclosing function's row: a
function declared #fx{} that handles only shallowly is TUR-E0009. A clause can
also choose what answers the rest by wrapping its resume in another handle:
(handle-shallow (two-asks) (Ask [] k) (handle (resume k 1) (Ask [] k2) (resume k2 3)))
is 31 on every engine. Compiled, that handle must be the clause's last form,
with the resume as its body
(shallow-clause-handle-around-resume-not-lowered).
perform is handled is checked dynamically. Unhandled effects raise an exception at runtime.Compose handlers for different effects by nesting handle forms -- the
inner handler runs inside the outer one, and each effect is routed to the
handler that declares it (compose-handlers does the same for handler values;
see the next section):
(defeffect Log [msg :cstr] :nil)
(defeffect Counter [] :int)
(handle
(handle
(do (perform (Log "tick")) (+ (perform (Counter)) 1))
(Counter [] k) (resume k 41))
(Log [msg] k) (do (println msg) (resume k nil)))
;; prints "tick", evaluates to 42
A handler is also a value: you can create one, pass it, apply it to a body, and compose two of them.
(handler (E [params] k) body) literal. It has
type (handler E V R) and carries the case detached from any body.(with-handler hv body) -- the body runs with
hv's dispatch table installed, exactly as if its case were written inline in
a handle. (with-handler with case/body pairs instead of a single value
argument remains the inline-handle sugar.)(compose-handlers h1 h2); h1 is the outer handler.(defeffect Log [msg :cstr] :nil)
(defeffect Counter [] :int)
;; compose-handlers builds one handler value over both effects (h1 = Counter is
;; the outer handler). Applying it is identical to the nested handle above.
(with-handler
(compose-handlers
(handler (Counter [] k) (resume k 41))
(handler (Log [msg] k) (do (println msg) (resume k nil))))
(do (perform (Log "tick")) (+ (perform (Counter)) 1)))
;; prints "tick", evaluates to 42
Semantics (precedence, the disjoint-effect rule, answer-type agreement, and per-case continuation discipline) are specified in first-class-handlers-semantics.md.
TUR-E0251); composition is defined only for disjoint effect
sets.(with-handler hv body) removes hv's
handled effect(s) from the body's effect row; a leftover (unhandled) effect
propagates to the enclosing function just as it does for handle.^linear / ^multishot / the
default affine k are enforced inside a handler value identically to inline
handle, and composition never blends two handlers' disciplines.Effects enable ergonomic async/await (see Async/Await Guide):
(async
(await (read-file "data.txt"))
(println "done"))
Implement generators, early returns, or custom exception handling:
;; Generator: yield values one at a time
(defeffect Yield [v :int] :nil)
(handle
(do
(perform (Yield 1))
(perform (Yield 2)))
(Yield [v] k) (do (println v) (resume k nil)))
Mock I/O operations in tests:
(defeffect ReadFile [path :cstr] :str)
;; Production
(handle code
(ReadFile [path] k) (resume k (read-file-real path)))
;; Tests
(handle code
(ReadFile [path] k) (resume k "mock data"))
Automatic conflict resolution (see STM Tutorial):
(defeffect Retry [] :nil)
(handle
(fn []
(when (< (read-tvar x) 10)
(perform (Retry))))
;; Re-run transaction on conflict
(Retry [] k) (resume k nil))
Effect rows track which effects a function may perform as part of its type. The compiler infers rows automatically; you can also annotate them explicitly.
An effect row appears between the parameter list and the return type:
;; Annotated: may perform the Write effect
(defn log-msg [msg : cstr] #fx{Write} : nil
(perform (Write msg)))
;; Pure: performs no effects
(defn add [a : int b : int] #fx{} : int
(+ a b))
;; Row-polymorphic: propagates the row of the function argument
(defn run-twice [f :(fn [] #fx{e} int)] #fx{e} : int
(+ (f) (f)))
The row #fx{e} is a row variable: run-twice performs whatever effects f performs, no more.
| Flag | Effect |
|---|---|
--dump-effects |
Print each top-level defn's inferred effect row after checking |
--strict-effects |
Warn (TUR-W0030) on any function -- a defn, a fn literal, an instance method -- that performs effects but declares no row; also TUR-W0032 on a row variable that is always concrete. Callers propagate the inferred row either way |
-Werror=strict-effects |
Make the --strict-effects warnings errors, so a build can gate on annotations. Implies --strict-effects |
--lint-effects |
Deprecated alias for --strict-effects (TUR-W0050); it was a duplicate of it |
A fn literal is named in these messages by where it is -- anonymous function
in 'dfs-or' -- with the span on the literal itself, and the advice spells the
row to add (add #fx{Bt} after its parameter vector).
Effects can be declared ^private to prevent leakage outside their defining module:
(defeffect ^private InternalLog [msg :cstr] :nil)
A ^private effect cannot be performed or handled outside the module that declares it.
Cross-module effect rows are automatically filtered: if a callee internally performs a private
effect, the caller's inferred row does not include it.
To export an effect explicitly:
(defmodule MyLib
(export (effect Write) (effect Read))
...)
Other modules import it with :refer [(effect Write)].
^capability)The algebraic effects above (Write, Read, Log, ...) are performed and
handled. Some side effects, though, are not expressed with perform at all --
they happen inside inline-C or extern-c calls (touching the file system,
opening a socket, reading the clock). To bring those under effect-row
discipline, declare a capability effect with ^capability:
(defeffect IO [] :nil ^capability)
(defeffect FS [] :nil ^extends IO ^capability)
A capability effect is a coarse authority tag rather than something you
perform. It behaves differently from an ordinary effect in two ways:
#fx{FS} is never flagged
as over-annotated (TUR-W0031), even though its body never performs FS --
the annotation is the justification.#fx{} (pure) caller of an #fx{FS}-tagged function
therefore fails effect-row checking with TUR-E0009, exactly like the
built-in #fx{Unsafe}.Five capability tags are compiler-known, like Unsafe: they resolve in
every program, with nothing loaded, and are used to annotate the I/O-touching
modules. stdlib/effects.tur
re-declares them, which is where their docs live; a declaration of one of these
names is accepted only when it matches the built-in exactly ((defeffect FS [] :nil ^extends IO ^capability)), and anything else is an error.
| Tag | Used by |
|---|---|
#fx{IO} |
umbrella, parent of the others |
#fx{FS} |
fs.tur, csv.tur file helpers, tmpfile |
#fx{Net} |
net.tur, async_socket.tur, httpd.tur |
#fx{Proc} |
process.tur, env.tur |
#fx{Rand} |
random.tur |
A sixth, Bt, is an ordinary ^capability declaration in the autoloaded
trail.tur, next to the trail mutators it annotates:
| Tag | Declared in | Used by |
|---|---|---|
#fx{Bt} |
trail.tur |
every trail mutator, bt-scope, with-untrailed, dfs-solve |
Bt is not under IO: mutating the backtracking trail is process-local
state, not an authority over a resource outside the process. See the
Backtrackable State Guide.
Every name in #fx{...} must be declared. An uppercase name that no
defeffect in the compile declares (and that is not compiler-known) is
TUR-E0026, with a did-you-mean for a near miss and the module to load for
the stdlib/effects.tur names:
error [TUR-E0026]: unknown effect 'Write' in effect row: no defeffect declares
it ('Write' is declared in stdlib/effects.tur, which is not autoloaded;
add (load "stdlib/effects.tur"))
It used to be dropped silently, so #fx{Typo} checked as #fx{} -- and a
caller's #fx{} then passed the TUR-E0009 check the tag existed to buy.
That is how #fx{Bt} sat decorative on the trail mutators for a month before
Bt was declared. The same check caught a stdlib #fx{FS} that had never
resolved and a fixture's #fx{|e} (a row variable is just e). #fx{...}
holds effects and row variables only: compiler attributes are ^attrs
(^construct, ^byval, (match ^non-exhaustive ...)).
Discipline stays opt-in: a function with no effect-row annotation is never
checked, so existing code that ignores effect rows keeps compiling. Only when a
caller annotates its own row (e.g. declares #fx{} or #fx{Net}) does the compiler
enforce that it has the capabilities of everything it calls.
(import tur/fs :refer [fs/read-text])
;; OK: declares the FS capability it relies on.
(defn load-config [path : cstr] #fx{FS} : (Result cstr IoError)
(fs/read-text path))
;; ERROR (TUR-E0009): claims purity but reaches the file system.
(defn load-config-bad [path : cstr] #fx{} : (Result cstr IoError)
(fs/read-text path))
;; OK: un-annotated, so the row is not checked at all.
(defn load-config-unchecked [path : cstr] : (Result cstr IoError)
(fs/read-text path))
println or (perform (Write s))?There are two ways to write a line, and they are not redundant.
println is a builtin that declares #fx{IO}. Printing is tracked:
an annotated function that prints must say so, and #fx{} means does not
even print. It is not handleable -- a capability tag is declared, never
performed -- so nothing can intercept, redirect or capture what it writes.(perform (Write s)) is an algebraic effect from stdlib/effects.tur
(Write ^extends IO). A handler decides what happens to the line:
with-write prints it, a test handler can collect it into a buffer, another
can drop it.Reach for println for output you are not trying to control, and for
perform (Write s) when a caller should be able to intervene. In Haskell
terms, println is putStrLn -- visible in the type as IO, concrete --
and Write is an output effect in an effect library, abstract until a
handler interprets it. (Before println carried #fx{IO} it was closer to
Debug.Trace.trace: output the types could not see.)
(load "stdlib/effects.tur") ; for Write; IO needs no load
;; ERROR (TUR-E0009): prints, so it is not pure.
(defn add-noisily [a : int b : int] #fx{} : int
(println "adding")
(+ a b))
;; OK: says it prints.
(defn add-loudly [a : int b : int] #fx{IO} : int
(println "adding")
(+ a b))
;; OK: a handler decides where the line goes.
(defn add-logged [a : int b : int] #fx{Write} : int
(perform (Write "adding"))
(+ a b))
IO is the parent of Write, FS, Net, Proc and Rand, so #fx{IO}
covers a function that prints and also performs Write or reads a file;
#fx{Write} alone does not cover println. Unannotated code is unaffected,
as everywhere else: only a function that declares a row is checked against it.
^fat callback parameters as well: a parameter typed (fn [T] #fx{E} R) with a non-empty row is callable, and a named effectful defn can be passed as its value (see Fat Closure Annotation Guide).TUR-E0009).^capability tags put inline-C side effects (FS, Net, Proc, Rand) and printing (IO, on println) under the same row checking, opt-in per caller.--dump-effects shows the full effect signature of every function.Effects interact with Turmeric's defer mechanism:
defer cleanup runs correctly even when perform is inside the same do block (see Custom Effects Tutorial).defer boundary is handled: the continuation's environment is cleaned up if it crosses a defer boundary.Delimited control in Turmeric is built on a single multi-prompt substrate (the Dybvig--Peyton-Jones--Sabry model). On the compiled path that substrate is the DK continuation chain; the CPS and DK internals guide describes it in full. The operators you already use map onto prompts and sub-continuations:
| Operator | Prompt action |
|---|---|
reset |
push a fresh prompt |
shift |
capture the sub-continuation up to the nearest prompt; re-install that prompt on resume |
shift0 |
capture up to the nearest prompt; do not re-install it on resume |
handle / perform |
handle pushes a handler marker per case; perform captures up to the nearest marker for its effect. A deep handler re-installs itself on resume; handle-shallow does not |
call/cc* |
capture a multi-shot sub-continuation (re-enter it any number of times) |
undelimited call/cc |
a one-shot upward escape to the call/cc site, at any depth |
Two properties of the substrate matter in practice:
Unbounded capture. A continuation is a heap-reified chain of frames, not
a fixed-size native-stack snapshot, so a perform captures correctly at any
call depth.
Implicit root prompt. Every entry from direct-style code into a colored
function seeds a root prompt. A capture that finds no enclosing reset still
has a boundary to capture up to, and await suspends to it.
Performance is meant to be preserved by selectivity: functions that can
reach a control operator (shift/perform/call/cc/...) are CPS-converted
(the "coloring" analysis, viewable with --dump-cps-coloring), and
direct-style code keeps its native calling convention, with no trampoline and
no continuation frames to allocate.
Today the coloring is wider than that, and it costs. The analysis cannot
see through a call to a function value -- a parameter f, a closure bound in
a let, a typeclass method reached through a dictionary -- so it treats every
such call as one that may reach a control operator. The enclosing function is
colored, and the color spreads to its callers; constructors, inline-C bodies
and extern-c functions are the only callees exempt (cps_collect_calls and
cps_color_program in src/passes/cps.c). A colored function pays about 2
allocations and 200 bytes of continuation frames per call through a function
value: see the memory usage guide's
Calls through function values, and effects.
The rule is incidental, not part of the effect system's contract (decided
2026-10-11; performance-audit item R-1), and it is not a corner case: over
the fixture tree, the examples and the benchmarks, 72% of CPS-colored
functions are colored by this rule alone -- no control operator of their
own and none reachable through a named callee
(WP2's R-1 census).
TUR_CPS_CENSUS=1 tur emit-c f.tur prints one line per function with the
reason it was colored (control, callee or indirect). The census also
shows why the fix is not a one-line narrowing: an empty effect row on a
function type does not yet mean "pure". This paragraph changes again when
R-1's fix lands. The
CPS and DK internals guide has
the full coloring rule and the reasons a colored function may still be emitted
direct-style.
A perform reachable from a while body is the shape of every event loop
and every "perform per item" traversal, and it is supported: the loop lowers
to a recursive __cps helper that threads the enclosing handler chain, so an
interior effect reaches an outer handler and the loop resumes where it left
off. A perform inside an if/when arm in statement position is likewise
fine -- the code after the conditional runs exactly once per resume.
(defeffect Done [score : int] : nil)
(defn run [] : nil
(let [^mut i 0]
(while (< i 10)
(when (= i 3) (perform (Done i))) ; abort or resume, either way
(set! i (+ i 1)))))
The lowering carries a loop's ^mut state in the helper's parameters when a
variable is assigned once and unconditionally, and in a shared cell otherwise
(assigned inside an if/match arm, more than once per iteration, or read
in a branch after its assignment), so the game-loop shape -- a tick
accumulator decremented inside a when -- lowers as written. A loop followed
by more statements that read the carried state is fine too. The limits that
remain evict the function to the direct emitter, with a located error at the
perform:
while nested inside a while that performs;(when c (set! x (perform ...))) -- assign the performed value to a let first);return inside a while that performs, when more code follows the loop
(make the loop the function's last form, or carry the result out in a
^mut and test it after the loop).TUR_TRACE_EVICT=1 prints which form evicted which function, and
TUR_TRACE_CORE=1 names the form the structural check rejected.
call/cc, escape)call/cc and escape capture an undelimited continuation against the
implicit program-wide prompt. They are enabled by default -- no flag and no
enclosing reset is required. (The legacy -Xcallcc flag is still accepted
as a deprecated no-op.)
;; k aborts the pending (+ 100 ...) and returns 41 at the call/cc site; the
;; outer (+ 1 ...) makes 42 -- with no enclosing reset.
(defn answer [] : int
(+ 1 (call/cc (fn [k] (+ 100 (k 41)))))) ; => 42
;; f that ignores k just returns its body value.
(defn plain [] : int
(+ 1 (call/cc (fn [k] 10)))) ; => 11
Undelimited vs. delimited. This is the one thing call/cc adds over
shift/reset: capture reaches the implicit root prompt, not the nearest
reset. A nearer explicit reset does not shorten call/cc/escape's
reach -- use shift/shift0 or call/cc* when you want delimited capture.
| Operator | Capture extent | Re-install prompt on (k v)? |
|---|---|---|
shift |
nearest reset |
yes |
shift0 |
nearest reset |
no |
escape |
implicit root prompt (undelimited) | no (abort flavor) |
call/cc |
implicit root prompt (undelimited) | n/a (one-shot upward) |
call/cc* |
nearest cloneable-reset |
n/a (multi-shot) |
escape is the abort flavor. (escape f) hands f a continuation whose
(k v) unwinds to the escape site with v without re-installing a prompt
(the shift0-style behavior). It is the idiomatic early-exit:
(defn first-positive [] : int
(escape (fn [k]
(when (> -3 0) (k -3))
(when (> 7 0) (k 7)) ; first positive: aborts here with 7
-1))) ; default if nothing matched
Typing. f has type cont<T> -> T, where T is the prompt's answer type;
the (call/cc f) / (escape f) expression itself has type T. An unannotated
continuation parameter ((fn [k] ...)) defaults to the escape continuation
flavor, so the (k v) application sugar lowers to the right resume runtime --
you do not have to spell :escape-cont or call tur_escape_resume by hand.
Annotate explicitly (:cont, :escape-cont, :serial-cont) only when you want
a non-default flavor.
One-shot. The captured k is one-shot by nature: (k v) unwinds to the
call/cc site, so a second call after it on the same path never runs (the
checker does not flag an unannotated k called twice). Opt into exactly-once
accounting with ^linear, which a second call breaks (TUR-E0101) and a path
that never calls k breaks too (TUR-E0100):
(defn use-once [] : int
(call/cc (fn [^linear k] (k 42)))) ; k must be invoked exactly once
For a multi-shot, cloneable/re-enterable continuation use call/cc* instead
(captured against an enclosing cloneable-reset); see the
Logic Programming Guide.