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

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:

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

History: compose-handlers was 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.

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] #{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.

Compiler flags

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

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:

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

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

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.

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

See Also