Purity Key idea: Real programs stack more than one effect at once — and Haskell has three generations of tools for doing that cleanly

Effects Beyond IO

A quick, necessary detour into RankN types — quietly already at work in runST and Lens — followed by the real subject: what happens once a program needs more than one effect at a time, and the three generations of tools Haskell has built to keep that tractable.

A necessary detour: polymorphism, generalized

Ordinary polymorphism, the kind every chapter so far has used without comment, has a shape worth naming precisely before this chapter goes further: in twice :: (a -> a) -> a -> a, the forall a implicitly out front belongs to the caller. Whoever calls twice picks one concrete type — Int, say — and twice spends the entire call using exactly that one type, never anything else. This is rank-1 polymorphism: one forall, sitting outermost, resolved once, before the function body ever runs.

Nothing stops a forall from appearing inside an argument’s own type, though — and the moment it does, something genuinely new becomes possible:

Rank-1 polymorphism where the caller picks the type once, versus rank-2 where the function picks it multiple times internally

Figure: The difference is entirely about who chooses the type, and how many times. A rank-1 forall is chosen once, by the caller, before the function starts; a nested forall is chosen by the function itself, as often as it likes, internally.

{-# LANGUAGE RankNTypes #-}

applyToBoth :: (forall a. a -> a) -> (Int, Bool) -> (Int, Bool)
applyToBoth f (i, b) = (f i, f b)

ghci> applyToBoth id (5, True)
(5,True)

applyToBoth takes a function that must work for every type — not one type the caller picks, but literally all of them — and is free to apply it at Int in one place and Bool in another, within a single call. An ordinary rank-1 signature, (a -> a) -> (Int, Bool) -> (Int, Bool), could never express this: the caller would fix a to be Int or Bool once, and the other application simply wouldn’t typecheck.

⚠Common Pitfall

You’ve already used a rank-2 type twice in this book without being told its name. Purity’s runST :: (forall s. ST s a) -> a needs that inner forall s for exactly this reason — it’s what stops a TVar-style state token from ever escaping the ST computation it belongs to, the same trick this section just demonstrated with applyToBoth. And Optics’ entire Lens s a = forall f. Functor f => (a -> f a) -> s -> f s is a rank-2 type by construction: the moment a Lens is passed as an argument to view, set, or ., that inner forall f is exactly what lets each caller instantiate f differently — Const here, Identity there — without Lens itself needing to know or care which.

★Cool Fact

“Rank” counts how deeply nested inside argument positions a forall sits — rank-1 is outermost (ordinary polymorphism), rank-2 has one level of nesting (as above), and Haskell in principle allows arbitrarily higher ranks, though rank-2 covers the overwhelming majority of real uses. The RankNTypes extension is what unlocks writing any of this explicitly; without it, GHC only infers rank-1 types on its own.

With that vocabulary in hand, the rest of this chapter can name precisely what’s happening under the hood of the tools it introduces.

The transformer stack problem

Monads (Chapter 11) already showed MaybeT and StateT stacking on top of another monad, one layer at a time. Real programs often need several effects simultaneously — database access, configuration, logging, error handling — which means stacking several transformers together:

type App a = ReaderT Config (StateT AppState (ExceptT AppError IO)) a

This works, but it has a genuine scaling problem. Every operation that touches the state — get, put, modify — is defined for StateT’s own monad. To use it three layers down inside App, it needs to be manually threaded through ReaderT’s and ExceptT’s lift, every single time:

-- without help, every operation needs explicit lifting through every layer above it
incrementCounter :: App ()
incrementCounter = lift (modify (\s -> s { counter = counter s + 1 }))

Stack four or five transformers deep, in different orders in different parts of a codebase, and the number of lifts needed for any given operation depends on exactly where in the stack its underlying monad sits — a genuinely awkward, error-prone bookkeeping problem that gets worse, not better, as an application grows.

mtl: one typeclass per effect, not per stack position

The classic fix, from the mtl (monad transformer library) package, is to stop thinking in terms of “how many layers down is StateT” and instead define one typeclass per effect, with instances that know how to reach through any stack automatically:

class Monad m => MonadState s m where
  get :: m s
  put :: s -> m ()

-- mtl provides instances so MonadState "sees through" any stack containing StateT,
-- no matter how deep, without manual lift calls
incrementCounter :: (MonadState AppState m) => m ()
incrementCounter = modify (\s -> s { counter = counter s + 1 })

incrementCounter no longer mentions App, ReaderT, or ExceptT at all — it just asks for MonadState AppState m, and works unchanged whether m is App directly, a test stack with fewer layers, or a completely different stack that happens to also carry AppState somewhere inside it. MonadReader and MonadError do the same for ReaderT and ExceptT. The lifts haven’t disappeared; mtl’s typeclass instances are doing them automatically, once, in library code, instead of by hand, everywhere they’re needed.

λA Category-Theoretic View

MonadState, MonadReader, and friends are themselves ordinary typeclasses of the kind Chapter 6 introduced — the only thing “advanced” about mtl is how many instances it needs to make lifting automatic through arbitrarily deep, arbitrarily ordered stacks. For n transformer types, a naive approach needs roughly n² instances (one for each transformer, lifting each effect) — which is exactly the scaling problem mtl’s design is built to hide from the programmer, even though the instance count underneath hasn’t actually gone away.

The next generation: effect systems

mtl solves the lifting problem but keeps the underlying idea of “stack concrete monad transformers, in a concrete order.” A newer generation of libraries — effectful and polysemy among the most established — reframes the problem again: instead of stacking transformers, you declare a set of effects your code needs, with no fixed stack or ordering at all, and only decide how to actually run each effect (in IO, in a pure test double, logged to a file) at the very edge of your program.

-- effectful: effects are listed, not stacked, in the type signature
program :: (State AppState :> es, Error AppError :> es, IOE :> es) => Eff es ()
program = do
  modify (\s -> s { counter = counter s + 1 })
  liftIO (putStrLn "incremented")

The practical draw is real: no lift, no fixed transformer order to get wrong, and — for effectful specifically — a design built from the ground up to avoid mtl’s worst performance pitfalls at deep stack depths. The tradeoff is a genuinely different mental model from the monad-transformer stacks this whole book has built up to, which is exactly why this chapter introduces it last, as the frontier rather than the default.

In the Wild

Reaching for mtl alone remains extremely common and entirely reasonable for small-to-medium applications — it’s been the standard approach for over a decade, is what most Haskell tutorials still teach first, and plenty of production codebases never need anything more. effectful and polysemy-style libraries earn their keep specifically at larger scale, where a growing number of effects and a need for swappable interpreters (real database in production, in-memory fake in tests) start to strain what hand-stacked transformers comfortably support.

Three generations, one throughline: IO (Chapter 7) already established that effects are ordinary values, not special statements. Monad transformers stack that idea; mtl hides the stacking’s bookkeeping; effect systems remove the stack’s fixed shape entirely. Each generation is a direct answer to friction the previous one left behind — not a rejection of what came before it.