Haskell World logo Haskell WorldWrite code, build worlds
Tutorials

Haskell Monad Transformers Step by Step With a Real App

You have a function that needs configuration, can fail with a typed error, and has to print to the console, and suddenly every signature is a tower of nested case expressions.

A developer at a standing desk tracing a diagram of stacked layers on a whiteboard beside a laptop showing Haskell code

You have a function that needs configuration, can fail with a typed error, and has to print to the console, and suddenly every signature is a tower of nested case expressions. Haskell monad transformers exist to flatten that tower, but most tutorials stop at a toy counter in StateT and never show what a real application stack looks like.

This guide builds one small program from plain IO up to a three layer stack, explains what each layer actually costs you, and ends with the pattern most production Haskell code settles on. If you are still shaky on the Monad class itself, read our walkthrough of type classes with real examples first, because transformers are mostly instance resolution in disguise.

The problem Haskell monad transformers solve

Picture a tiny tool that reads a config, loads and validates a user, and prints a greeting. In plain IO it looks fine for twenty lines. Then you add error handling with Either, and every step becomes a case expression that unwraps a Left or a Right before moving on. Add the config record and you are passing it through six functions that only forward it to a seventh. The logic drowns in plumbing, and plumbing is where bugs hide.

A monad transformer takes an existing monad and adds one capability to it. ReaderT adds read only access to an environment. ExceptT adds short circuiting failure with a typed error. StateT adds a value you can read and replace. Stack them and you get one monad that has all three abilities, with the plumbing handled by the bind operator instead of by you.

Building the stack one layer at a time

Start with the innermost layer, which is almost always IO in an application.

Step 1: add ReaderT for configuration

Define a Config record with fields such as the data directory and a verbosity flag. Your monad becomes ReaderT Config IO. Inside it, ask returns the whole record and asks dataDir returns one field.

Step 2: add ExceptT for typed errors

Define an AppError sum type with constructors like UserNotFound and InvalidAge. The stack becomes ExceptT AppError (ReaderT Config IO). Now throwError (UserNotFound name) stops the computation, and the caller decides what to do with it at the edge of the program by running the stack with runExceptT and pattern matching once.

Order matters here. With ExceptT on the outside of ReaderT, an error discards nothing important because Reader has no state to lose. Put ExceptT around StateT, though, and a thrown error also throws away the state changes made before it. Put StateT around ExceptT and the state survives. Neither is wrong; you have to pick on purpose.

Step 3: lift IO actions through the layers

A plain IO action like putStrLn does not type check inside the stack because its type says IO, not your stack. liftIO from Control.Monad.IO.Class fixes this in one call regardless of how many layers sit above IO, whereas chains of lift break whenever you add a layer.

Step 4: give the stack a name

Write a newtype App wrapping the full stack and derive Functor, Applicative, Monad, MonadIO, MonadReader Config and MonadError AppError with GeneralizedNewtypeDeriving. Then one runApp function unwraps it: it takes a Config and an App action and returns IO (Either AppError a). Every function in the program now has a signature like loadUser :: Text -> App User, which reads as plain intent.

The newtype matters more than it looks. It keeps error messages short, since GHC prints App instead of three nested type constructors, and it lets you change the stack later without editing every signature. If a stack error does slip through, our guide to reading GHC error messages without panicking shows how to find the line that matters.

mtl style constraints versus a concrete stack

The mtl library adds classes such as MonadReader, MonadState and MonadError. Instead of naming the concrete stack, a function can say it needs (MonadReader Config m, MonadError AppError m) => m User. Any monad with those abilities will do.

This helps library code and tests, at the cost of longer type errors and possible slowdowns if GHC does not specialise it. For application code, a concrete App newtype with mtl instances derived on it gets you most of the benefit with fewer surprises.

LayerAddsKey operationsWatch out for
ReaderT rRead only environmentask, asks, localAlmost nothing; it is the safest layer
ExceptT eTyped short circuit failurethrowError, catchErrorDoes not catch IO exceptions
StateT sMutable looking valueget, put, modifyState lost on exceptions; lazy version leaks
WriterT wAccumulated logtellLeaks space in long runs; avoid in services

Why the tall stack is often the wrong answer

Most tutorials present transformers as something to stack generously. In long running services, that advice ages badly, and the reason is exceptions.

ExceptT only handles errors you throw with throwError. A file that cannot be opened, a network timeout or a killed thread raises an IO exception that bypasses ExceptT entirely. So your code now has two error channels, and every handler has to think about both. StateT has a related flaw: when an IO exception escapes, the state accumulated inside StateT is gone, and functions like bracket and finally from base cannot restore it because they only know about IO.

WriterT is worse. The standard lazy version builds up thunks as you call tell, and the strict version in older transformers releases is not strict enough to stop it. A service that logs through WriterT for a few hours can show the same symptoms we walk through in finding and fixing a Haskell space leak in production.

The common fix is the ReaderT design pattern: keep the stack at exactly ReaderT Env IO. Mutable state lives in an IORef or TVar stored inside Env. Errors are ordinary exceptions thrown with throwIO, with your own exception types, and caught where you can do something useful. Logging is a function stored in Env. You lose some purity, but you gain one error channel, predictable state under exceptions, and compatibility with libraries like unliftio that need to run your monad back in IO.

ExceptT is still fine inside pure validation code with no IO. The pushback is against stacking it around IO.

A practical checklist before you add a layer

  1. Ask whether the capability is truly needed across many functions, or only in two. If two, pass an argument.
  2. If you want state in IO code, prefer an IORef in a Reader environment over StateT.
  3. If you want typed errors, decide whether IO exceptions also need handling. If yes, use exception types and throwIO.
  4. Wrap whatever you choose in a newtype from day one.
  5. Use the strict StateT from Control.Monad.State.Strict in pure code and avoid WriterT in anything long running.

Frequently asked questions

What is the difference between transformers and mtl in Haskell?

The transformers package defines the concrete types such as ReaderT, StateT and ExceptT, plus lift. The mtl package builds on it and adds classes like MonadReader and MonadState so you can call ask or get without manual lifting. Most projects depend on both.

Does the order of monad transformers matter?

Yes. ExceptT outside StateT discards state on error, while StateT outside ExceptT keeps it. ReaderT is usually order insensitive because it has no state to lose. Pick the order based on what you want to happen when something fails.

Are monad transformers slow in Haskell?

A concrete stack of two or three layers compiles to fast code with optimisations on, and the overhead rarely shows in profiles next to IO. Polymorphic mtl code can be slower if GHC cannot specialise it, which a SPECIALISE pragma or a concrete App type usually solves.

AS
Adil Sato

Adil Sato teaches programming concepts at a community college and writes online guides for the questions students ask most in the second week of class, not the first. His focus is on making type systems, monads, and functional patterns understandable without reducing them to metaphors that break down the moment you try to use them for real work.

More posts by Adil

More in Tutorials

01doorganico 084goad 3hot8 4465456 after5internprogram ahandfulofblessings ahmadpc ahomcybersecurity allodialresources annodex antiquesandperiodcollectibles aotc asianworldkashmir beedlectrics bloggertemplatefree bluefeatheragency bodyweightsolution campaign08blog carfixadvisor catstreetcollective cbstvsports cnfanspurchasing committeeof500yearsofdignityandresistance covertopsdjs cs-kaiyuangame cuestacomunicaciontotal cxoutlet dancebetgame deskgearhub destinyvillastay detectmagazine di33 diamondsteelbuildings donmo dyanadropship emarsysfinance emimattl escapechan eskitarih evautoplay everythingweddings-orlando farmzone feedviewerapp foxstudiosscratch gamevui123 gleasongrade greetingsday hacklabasu iw-kaiyun jagoanproperti jilidskripsi joltbusinessfinance juegodepokergratis juegospucca kaeru-festival latestmehndidesigns lestanzediottaviano litemagazines localcommunityhero lustrsazan mariashebets menswheels newwebstechs1 nikkit ootheday oxigenvpn plurfitness prefetial probudgetcarpetcleaning psychicmrsmartin pyaten qvantor rejoignezlogis rexzbot riselayouts ruchin rwxqsgd5 sakaltime shibiaowang shop-bot skrtips strcoding tetapatrium topselller uneplanteartificielle vipertechcr webtech1223411 wo-kygaming yamaha-mekar yeknava yoybuysheets zenunwa yamaha-mekar jilidskripsi ankertoto ankertoto ankertoto ankertoto