Monad transformers, Free, Tagless final
AdvancedThe algebraic hierarchy gives you individual effects (Maybe, Either, State, Reader, Writer, IO) but doesn't tell you how to combine them — what if a computation can fail and read from an environment and log? You need a stack of effects. There are three classical answers, each fixing the problems of the last: monad transformers, free monads, and tagless final.
The problem: monads don't naturally compose
Given two monads M and N, the type M (N a) is not in general a
monad. Maybe (List a) doesn't compose cleanly: there's no canonical way
to join an Maybe (List (Maybe (List a))). That's the gap each technique
fills, differently.
1 · Monad transformers
A monad transformer T turns a monad M into a richer monad
T M. Each effect ships with its transformer:
| Effect | Transformer |
|---|---|
| Failure (Maybe) | MaybeT |
| Errors (Either e) | ExceptT |
| State s | StateT |
| Reader env | ReaderT |
| Writer w | WriterT |
You stack them: StateT s (ExceptT e IO) a is "a computation that has
state s, can fail with e, performs IO, and produces a."
Cost: lift boilerplate (you must hoist inner-monad actions through
the stack), and the order of the stack matters semantically — StateT s (ExceptT e Identity) rolls back state on error, while ExceptT e (StateT s Identity) keeps it.
This is the mtl style in Haskell, and the dominant pattern through the 2010s.
2 · Free monads
A free monad turns any functor F into a monad — for free. The trick:
represent your program as a tree of effects without running them, then
interpret the tree later.
data Free f a = Pure a | Roll (f (Free f a))
You define your effect language as an algebraic data type:
data ConsoleF a = GetLine (String -> a) | PutLine String a
type Console = Free ConsoleF
prog :: Console ()
prog = do
n <- getLine
putLine ("hello " ++ n)
prog is a value — a tree describing the program. You then write
interpreters:
runIO :: Console a → IO a(the real interpreter)runPure :: [String] → Console a → ([String], a)(test interpreter with mocked input)
Win: clean separation of what the program does from how it runs; trivially testable; you can analyze the tree (count effects, optimize).
Cost: runtime overhead from building and walking trees; cumbersome for deep effect stacks (the famous n² problem when combining many algebras).
3 · Tagless final
The tagless final style encodes the effect language not as data but as a typeclass / interface. The program is polymorphic in the carrier monad:
class Monad m => Console m where
getLine' :: m String
putLine' :: String -> m ()
prog :: Console m => m ()
prog = do
n <- getLine'
putLine' ("hello " ++ n)
You write multiple instances: instance Console IO, instance Console TestM, etc. The program is the same code; you swap interpreters by
choosing the monad at the call site.
Win: zero runtime overhead (no tree, just function calls); composes better than mtl for big effect sets; easy to mock.
Cost: requires higher-rank type machinery in Haskell; harder to inspect/optimize a program (no data, just behavior).
Which one when?
| Need | Use |
|---|---|
| Small, fixed effect set; speed matters | mtl (transformers) |
| Want to inspect / optimize / serialize programs | Free (and Freer, Eff) |
| Maximum flexibility, polymorphic libraries | Tagless final |
| Modern alternative to all three | Algebraic effects (see next topic) |
Clojure analog
Clojure doesn't use any of these directly — its idiom is just pass maps
around, with dynamic vars for "ambient" effects. But the idea of
tagless final shows up clearly in:
- Protocols as effect interfaces: pass an
IOprotocol around, swap implementations for tests. component/integrant— explicitly threading the effect stack rather than smuggling it through types.
That's tagless final in Clojure clothing.
Real-world: which approach where
| In production | Approach |
|---|---|
| Haskell at Mercury, Standard Chartered, Tweag | mtl for small services, effectful / polysemy / fused-effects for larger ones — all evolutions of these three ideas |
Scala with cats-effect, ZIO | ZIO[R, E, A] is literally a tagless-final / reader-writer transformer stack made ergonomic |
| Scala 3 / Effekt capability-passing | Tagless-final taken to the runtime: capabilities as implicits/contextual functions |
| Haxl at Meta | Built as a Free-monad-style data fetch graph that's analyzed/batched before execution — Free's "inspect the program" superpower |
| Eff in PureScript, Koka, OCaml 5 | Skip monad transformers entirely → see [[algebraic-effects]] |
| Clojure | Pass an explicit system map (Component / Integrant / mount); protocol-shaped dependencies = tagless final by hand |
The migration arc in industry has been: mtl → tagless-final → algebraic effects. Each step reduces boilerplate while keeping the "describe vs interpret" split. Clojure skipped the whole journey by passing maps and using protocols — different ergonomics, similar end result.
Check yourself
? quiz
Why does the order of monad transformers in a stack matter?
Exercise
Sketch (in pseudo-code or Haskell) the same readConfigAndIncrement
program three ways: as an mtl stack ReaderT Config (State Int), as a
free monad with a ConfigF | StateF functor, and as a tagless-final
program polymorphic in (MonadReader Config m, MonadState Int m). Compare
the boilerplate vs flexibility.