Turmeric uses algebraic effects instead of Haskell-style monadic chaining for most use cases. This guide explains the design decision and shows how each common monad use case maps to the Turmeric equivalent.
The classical Haskell signature:
Monad m => m a -> (a -> m b) -> m b
requires higher-kinded types -- m is a type constructor of kind * -> *.
Turmeric's typeclass system operates at kind * only. Extending to HKTs would
require kind inference, kind-polymorphic dispatch, kind-checking in the
elaborator, and new dispatch-table key shapes -- significant cost for a feature
whose motivation mostly evaporates once effects exist.
The architectural decision:
bind / pure are per-type functions, not a Monad typeclass.| Use case | Turmeric answer |
|---|---|
IO, async |
Effects (Io, Await) |
State |
Effects (Get, Set) |
| Exceptions / errors | Effects (Throw) or do-result macro |
Maybe / optional / short-circuit |
Effects (Fail) or do-option macro |
Either / Result |
Effects (Throw) or do-result macro |
Logger / Writer |
Effects (Log) |
List / nondeterminism |
Multi-shot effects (planned) |
| Parser combinators | Effects (Parse-Char, Parse-Fail) -- direct-style |
| Custom domain DSL | Per-type macro + typeclass-resolved bind |
This is what 80% of "monad chaining" turns into.
(defeffect Fail [] : a)
(defn lookup-port [cfg-key :cstr] :int @ {Fail Read-Config}
(let [s (perform (Read-Config cfg-key))]
(cond
(empty? s) (perform (Fail))
:else (parse-int s))))
(handle (lookup-port "http.port")
(Fail [] _) 8080) ;; default if anything in the chain fails
No >>=, no nested Just, no chains of match. Direct-style code that fails
through an effect.
(defstruct Cfg-Error [what :cstr where :cstr])
(defeffect Throw [e :Cfg-Error] : a)
(defn read-config [path :cstr] :Config @ {Throw Io}
(let [text (read-file path)
parsed (parse-toml text)]
(validate parsed)))
(handle (read-config "/etc/foo.toml")
(Throw [e] _) (do
(eprintln (str-concat "config error: " (.what e)))
(default-config))
(Io [op] k) (resume k (do-io op)))
Chains of bind threading Result<T, Error> become linear, direct-style
code. The handler is the only place errors are visible.
(defeffect Get [] :int)
(defeffect Set [v :int] :nil)
(defn counter-step [] :nil @ {Get Set}
(let [n (perform (Get))]
(perform (Set (+ n 1)))))
(defn run-with-state [init :int body] :(pair int a)
(let [s init
r nil]
(handle (set! r (body))
(Get [] k) (resume k s)
(Set [v] k) (do (set! s v) (resume k nil)))
(pair s r)))
(run-with-state 0 counter-step) ; => (pair 1 nil)
This is the State monad as an effect handler. The handler is the
interpretation; callers of counter-step don't see the threading.
(defeffect Parse-Peek [] :(option char))
(defeffect Parse-Take [] :char)
(defeffect Parse-Fail [] :a)
(defn digit [] :char @ {Parse-Peek Parse-Take Parse-Fail}
(let [c (perform (Parse-Peek))]
(cond
(none? c) (perform (Parse-Fail))
(is-digit? c) (perform (Parse-Take))
:else (perform (Parse-Fail)))))
Classical Haskell parser-combinator code looks like digit >>= \d -> ....
The effect version is direct-style -- looks like reading a stream, fails
through Parse-Fail. The handler interprets it.
Caveat. Backtracking parsers want multi-shot continuations (resume the same
kmore than once with different inputs). v1 effects are one-shot. Until multi-shot lands, backtracking parsers either (a) use an explicit input cursor withParse-Failand longest-match semantics, or (b) use a per-typeParservalue withbind. See the next section.
Some cases want a first-class "this is a value representing a computation":
For these, write a per-type bind and use a do-monadic macro:
;; Per-type bind functions -- no Monad typeclass needed.
(defn opt-bind [m :(option a) f :(-> a (option b))] :(option b)
(cond (some? m) (f (unwrap m)) :else none))
(defn opt-pure [x] :(option a) (some x))
(defn res-bind [m :(result a e) f :(-> a (result b e))] :(result b e)
(cond (ok? m) (f (unwrap-ok m)) :else m))
(defn res-pure [x] :(result a e) (ok x))
bind and pure are just functions per-type, not methods of a Monad
typeclass. Call them by name: (opt-bind ...), (res-pure ...).
do-monadic notationA macro lifts the chaining boilerplate. It takes the bind / pure names as
parameters, since there is no global "the Monad":
(defmacro do-option [bindings body]
`(do-monadic opt-bind opt-pure ~bindings ~body))
(defmacro do-result [bindings body]
`(do-monadic res-bind res-pure ~bindings ~body))
Usage:
(do-option
[x (lookup-int "port")
y (lookup-int "timeout")]
(opt-pure (+ x y)))
;; => (some 30) if both lookups succeed; none otherwise.
| Property | Turmeric | Haskell |
|---|---|---|
Monad m => polymorphism |
None -- pick a concrete monad per call site | Full HKT polymorphism |
do-notation |
Per-monad macros (do-option, do-result, ...) |
Single do polymorphic over Monad |
| Most "monad" use cases | Effect handlers (direct-style) | Monad transformers / mtl |
| Async / IO | Effects | IO monad |
| State | Effects | State monad |
| Errors | Effects | Either / ExceptT |
| Parsers | Effects | Parser combinator monad |
| First-class "computation values" | Per-type bind + macro | Polymorphic >>= |
| Multi-shot continuations | Planned (multi-shot effects) | Native via >>= for [] |
The deal Turmeric makes: trade type-level monad polymorphism for direct-style code via effects. Most "monad-heavy" programs become effect-using programs that don't look monadic at all. The cases that genuinely want monad-as-value get a per-type fallback.