Effects System Guide

This guide explains Turmeric's algebraic effects system and when to use it.

Overview

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.

Core Concepts

Effects, Performs, and Handlers

Three core primitives:

;; 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"))

One-Shot Continuations

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.)

Passing k to a helper

A 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

Discontinuing a continuation (beta)

(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)

Deep and shallow handlers: handle-shallow

A 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).

Properties

Composing Handlers

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

First-Class Handler Values

A handler is also a value: you can create one, pass it, apply it to a body, and compose two of them.

(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.

Common Use Cases

Direct-Style Async

Effects enable ergonomic async/await (see Async/Await Guide):

(async
  (await (read-file "data.txt"))
  (println "done"))

Custom Control Flow

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)))

Dependency Injection

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"))

Transactional Retry

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 (Typed Effects)

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.

Syntax

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.

Compiler flags

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).

Module-level visibility

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 effect tags (^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:

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))

Printing: println or (perform (Write s))?

There are two ways to write a line, and they are not redundant.

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.

Benefits

Integration with Ownership and Defer

Effects interact with Turmeric's defer mechanism:

The Prompt Model and Unbounded Capture (CPS substrate)

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:

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.

Performs inside loops and conditionals

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:

TUR_TRACE_EVICT=1 prints which form evicted which function, and TUR_TRACE_CORE=1 names the form the structural check rejected.

Continuations (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.

See Also