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.)
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:
(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.History:
compose-handlerswas briefly gated (TUR-E0704) while handler values had no runtime representation. That gate has been removed; handler values are now first-class. See first-class-handlers-plan.md.
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] #{Write} : nil
(perform (Write msg)))
;; Pure: performs no effects
(defn add [a : int b : int] #{} : int
(+ a b))
;; Row-polymorphic: propagates the row of the function argument
(defn run-twice [f :(fn [] #{e} int)] #{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 |
--lint-effects |
Warn on unannotated defns whose inferred row is non-empty |
--strict-effects |
Under --strict-effects, unannotated functions that perform effects get a warning; callers propagate the inferred row |
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}.The standard library ships five capability tags in
stdlib/effects.tur, used to annotate the
I/O-touching modules:
| 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 |
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] #{FS} : cstr
(fs/read-text path))
;; ERROR (TUR-E0009): claims purity but reaches the file system.
(defn load-config-bad [path : cstr] #{} : cstr
(fs/read-text path))
;; OK: un-annotated, so the row is not checked at all.
(defn load-config-unchecked [path : cstr] : cstr
(fs/read-text path))
TUR-E0009).^capability tags put inline-C side effects (FS, Net, Proc, Rand) 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 moving onto a single multi-prompt substrate
(the Dybvig--Peyton-Jones--Sabry model), built by
cps-transform-plan.md. 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 |
call/cc* |
capture a multi-shot sub-continuation (re-enter it any number of times) |
undelimited call/cc |
capture up to the implicit root prompt at program entry |
Two properties of the substrate matter in practice:
Unbounded capture. A continuation is a heap-reified closure chain, not a
fixed-size native-stack snapshot. Capturing it is O(1) and works at any call
depth -- the old 16-frame ceiling (tur_cont_alloc returning NULL past
TUR_CONT_MAX_CAPTURED_FRAMES) does not apply on this path. This is what lets
call/cc reach all the way back to the top of the program.
Implicit root prompt. There is a prompt around program entry, so a bare
call/cc with no enclosing reset still has something to capture up to and
resume.
Performance is preserved by selectivity: only functions that can actually
reach a control operator (shift/perform/call/cc/...) are CPS-converted
(the "coloring" analysis, viewable with --dump-cps-coloring); direct-style hot
code keeps its native calling convention and pays no trampoline or allocation
cost. See the plan for the full model.
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 old -Xcallcc gate, with its TUR-E0700 /
TUR-E0701 "unsound stub" diagnostics, is retired; -Xcallcc is now 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 ^unique by default (calling it more than once
is TUR-E0100 / TUR-E0101). Opt into exactly-once accounting with ^linear:
(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.