Skip to content
AgileLogics
8 min read

Monad Transformers

Prerequisites: State, Reader, and Writer

Real programs need several effects at once: configuration, state, errors, and IO. A monad transformer adds one effect on top of another monad, so you can stack them.

Transformer Adds Run with
ReaderT r m Read-only environment r runReaderT
StateT s m Changing state s runStateT, evalStateT
ExceptT e m Errors of type e runExceptT
WriterT w m Accumulated output w runWriterT
MaybeT m Failure without a message runMaybeT

State s is in fact defined as StateT s Identity — a transformer over the "do nothing" monad.

StateT over IO

import Control.Monad.State

type App = StateT Int IO

step :: String -> App ()
step msg = do
  modify (+ 1)
  n <- get
  liftIO (putStrLn (show n ++ ": " ++ msg))

main :: IO ()
main = do
  total <- execStateT (mapM_ step ["load", "parse", "save"]) 0
  putStrLn ("steps run: " ++ show total)
1: load
2: parse
3: save
steps run: 3

liftIO runs an IO action from inside any stack that has IO at the bottom.

A three-layer stack

import Control.Monad.Reader
import Control.Monad.State
import Control.Monad.Except
import Control.Monad (when)

data Config = Config {maxWithdrawal :: Int}

type Bank = ReaderT Config (StateT Int (ExceptT String IO))

withdraw :: Int -> Bank ()
withdraw amount = do
  limit <- asks maxWithdrawal
  balance <- get
  when (amount > limit) $ throwError ("over the limit: " ++ show amount)
  when (amount > balance) $ throwError "insufficient funds"
  put (balance - amount)
  liftIO (putStrLn ("withdrew " ++ show amount))

runBank :: Bank a -> Int -> IO (Either String (a, Int))
runBank action start = runExceptT (runStateT (runReaderT action (Config 500)) start)

main :: IO ()
main = do
  runBank (withdraw 100 >> withdraw 50) 1000 >>= print
  runBank (withdraw 100 >> withdraw 900) 1000 >>= print
  runBank (withdraw 600) 1000 >>= print
withdrew 100
withdrew 50
Right ((),850)
withdrew 100
Left "over the limit: 900"
Left "over the limit: 600"
  • The type alias Bank names the whole stack, so function signatures stay short.
  • runBank peels the layers off from the outside in.
  • Thanks to mtl (next lesson), asks, get, and throwError work without manually lifting through the layers.

Lifting by hand

With the plain transformers package, you call lift once for each layer you need to pass through:

import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Maybe
import Text.Read (readMaybe)

readNumber :: String -> MaybeT IO Int
readNumber s = do
  lift (putStrLn ("parsing " ++ show s))
  MaybeT (return (readMaybe s))

main :: IO ()
main = do
  r1 <- runMaybeT ((+) <$> readNumber "2" <*> readNumber "40")
  print r1
  r2 <- runMaybeT ((+) <$> readNumber "2" <*> readNumber "x")
  print r2
parsing "2"
parsing "40"
Just 42
parsing "2"
parsing "x"
Nothing

Order matters

StateT s (Either e) and ExceptT e (State s) behave differently: in the first, an error throws away the state; in the second, you get the state back even after an error. Pick the order that matches what you need to know after a failure.

The ReaderT pattern

Many production applications avoid deep stacks and use just ReaderT Env IO, where Env holds configuration, a logger, a database pool, and mutable state in IORefs or TVars. Exceptions are thrown in IO. It's simple, fast, and works well with concurrency.