Real programs need several effects together — read config and maybe-fail and do IO — and hand-nesting IO (Either e a) gets miserable fast. A monad transformer bolts one effect onto another so you keep one tidy do block. Scala devs: this is cats' EitherT / ReaderT / StateT.
By now you can wield a single effect comfortably: Maybe for absence, Either for errors, State for threaded state, IO for the outside world. One do block, one monad, clean. The trouble starts when a real function needs two of them at the same time.
This chapter is about that exact problem and its standard fix:
IO (Maybe a)) makes you unwrap and rewrap at every step.MaybeT, ExceptT, StateT, ReaderT) adds one effect on top of a base monad, giving you a single combined monad.lift / liftIO reach down to the base layer; MTL classes let you skip the counting.Say you want a lookup that does IO (hits the network) and might come back empty. The honest type is IO (Maybe a) — an IO action that yields a Maybe. Perfectly accurate. Also a pain to use.
The problem is that IO (Maybe a) is not a monad you can sequence in one go. do-notation binds the outer monad — IO — so every step hands you back a Maybe that you then have to pattern-match yourself before the next step:
do
mu <- fetchUser uid -- mu :: Maybe User
case mu of
Nothing -> pure Nothing
Just u -> do
ma <- fetchAccount (userId u) -- and again...
That staircase of cases is the "unwrap at every step" tax. In Scala you've felt the same thing the moment a Future[Option[A]] shows up in a for-comprehension — the for only flattens the Future, leaving the Option stranded inside.
You want the do block to short-circuit on Nothing by itself, the way a single-Maybe block already does — while still letting you run IO. There's a better way, and it has a name.
OptionT[Future, A] — the T ("transformer") wraps Future[Option[A]] into something you can sequence in one comprehension. Haskell's version is MaybeT IO a. Same idea, same letter.A monad transformer is a type that takes a base monad m and gives you back a new monad with one extra effect bolted on. Read the name as "this effect, over that base":
MaybeT m — absence, over m.ExceptT e m — typed errors of type e, over m.StateT s m — threaded state of type s, over m.ReaderT r m — a read-only environment r, over m.Each one is just a newtype wrapper around the nested type you'd have written by hand — plus a Monad instance that does all the unwrap-and-rewrap plumbing for you. For example:
newtype ExceptT e m a = ExceptT { runExceptT :: m (Either e a) }
So ExceptT Err IO a is IO (Either Err a) underneath — the same nested shape from section 1. The difference is that ExceptT comes with a Monad instance, so a single do block over it short-circuits on Left automatically while still running IO. You stop writing the staircase; the instance writes it for you.
Stack two or more and you get a tower of effects, all usable in one do. Build one below and watch the assembled type unfold into the plain nested type it stands for.
lift — reaching down a layerHere's the catch with a stack. When you're inside, say, MaybeT IO, the do block speaks the combined monad. But getLine is a plain IO String — it lives in the base layer, not the combined one. Drop it in raw and the types won't match.
lift is the adapter. It takes an action from the base monad and promotes it into the transformer sitting on top:
lift :: (Monad m) => m a -> t m a -- base action → one layer up
lift getLine :: MaybeT IO String
If the base is IO specifically, there's a convenience version, liftIO, that reaches all the way down to the IO layer no matter how tall the stack is — so you write liftIO (putStrLn "hi") once instead of lift (lift (lift ...)).
Think of it as Scala's EitherT.liftF / ReaderT.liftF — the same "take this plain effect and lift it into the stack" call. Watch an IO action rise through a three-layer tower until it's usable up top.
liftsSprinkling lift everywhere — and worse, lift (lift ...) as stacks grow — is tedious and brittle. Reorder the stack and your lift counts break. The MTL style (Monad Transformer Library) fixes this with typeclasses.
Instead of naming a concrete stack, you write a function against the capabilities it needs, as constraints:
MonadError e m → gives you throwError / catchError.MonadState s m → gives you get / put / modify.MonadReader r m → gives you ask / local.MonadIO m → gives you liftIO.Now you call throwError, get, or ask directly. The instances figure out which layer of the stack each call belongs to and route it there — no manual lifts to count. A signature reads as a list of powers:
validate :: (MonadReader Config m, MonadError Err m) => m Int
This is exactly cats-mtl's MonadError / Ask / Stateful, or the way you'd request capabilities with F[_]: Sync: ... in tagless-final Scala. You ask for what you need; the compiler wires it up.
Let's run all three effects in one block. Our app monad reads a Config, can fail with an Err, and does IO:
type App a = ReaderT Config (ExceptT Err IO) a
Read that top-down: a ReaderT sitting on an ExceptT sitting on IO. Inside one do block you can now use every layer's powers at once:
ask — pull the Config out of the ReaderT environment.throwError — bail out with an Err from the ExceptT layer.liftIO — run an IO action at the base.No case staircase, no manual unwrapping. The combined Monad instance threads the config, short-circuits on error, and sequences the IO — all behind the one do. The code card in section 8 shows this exact program side by side with its Scala twin.
To actually run it, you peel the layers in the opposite order you stacked them: runReaderT first (hand it the config), then runExceptT (turn it into IO (Either Err a)):
runExceptT (runReaderT app cfg) :: IO (Either Err Int)
Here's the part that bites everyone once. Stacking the same two transformers in a different order gives you different behaviour — not just a different type. The classic case is StateT with ExceptT:
StateT s (ExceptT e m) unfolds to s -> m (Either e (a, s)). The Either is on the outside of the state — so if you throwError, the (a, s) pair never gets built. The state is lost.ExceptT e (StateT s m) unfolds to s -> m (Either e a, s). The state s sits outside the Either — so it's threaded out regardless. The state survives the error.That's a real, observable difference: if you accumulate state, then hit an error, does the state you built up before the failure stick around? The answer is decided entirely by which transformer is on top. Flip the order below and run the "set state, then error" scenario.
StateT on top of ExceptT. "Keep the log even if we crash" wants ExceptT on top of StateT. Same two pieces, opposite semantics — choose deliberately, and write a test that pins it down.Let's be honest: transformers work, but the boilerplate is real. Tall stacks compile slowly, the lifts and instance plumbing add friction, and "order matters" is a footgun. The Haskell ecosystem has answered with several alternatives, and you should know they exist:
ReaderT IO pattern — collapse the whole tower into a single ReaderT Env IO that carries everything (config, mutable refs, logging) in its environment. Fast, simple, and the de-facto default for production apps. Errors go through plain IO exceptions or an Either in the result.effectful, polysembly-style freer effects, cleff, and fused-effects express capabilities as effects you interpret separately, dodging the rigid stacking order.If you've reached for ZIO in Scala — its ZIO[R, E, A] is essentially ReaderT R (ExceptT E IO) baked into one type with no stacking required — you already know the destination. Haskell is converging on the same idea: keep the three core powers (environment, typed errors, IO) but lose the hand-built tower. Transformers are the foundation everything else is explained in terms of, which is why they're worth understanding even as you move past them.
The cats-mtl version on the left, the Haskell version on the right. Same three effects — environment, error, IO — in one comprehension / one do.
You can now stack effects, lift between layers, request capabilities with MTL constraints, and reason about stacking order. The last chapter zooms out: the type system itself as a tool — GADTs, phantom types, type families — and a map of the ecosystem and your road ahead.