Skip to content
AgileLogics
6 min read

mtl-Style Classes

Prerequisites: Monad transformers

The mtl library defines a type class for each effect — MonadState, MonadReader, MonadError, MonadWriter, MonadIO. Instead of naming a concrete stack, a function states only the capabilities it needs:

import Control.Monad.State
import Control.Monad.Except
import Control.Monad (when, replicateM)

nextTicket :: (MonadState Int m, MonadError String m) => m Int
nextTicket = do
  n <- get
  when (n >= 3) $ throwError "sold out"
  put (n + 1)
  return (n + 1)

buyTickets :: (MonadState Int m, MonadError String m) => Int -> m [Int]
buyTickets k = replicateM k nextTicket

main :: IO ()
main = do
  print (runExcept (runStateT (buyTickets 2) 0))
  print (runExcept (runStateT (buyTickets 5) 0))
  print (runStateT (buyTickets 2) 0 :: Either String ([Int], Int))
Right ([1,2],2)
Left "sold out"
Right ([1,2],2)
  • nextTicket works in any monad that has Int state and String errors — the caller picks the concrete stack.
  • The last line runs the same code with Either String as the base monad instead of Except.
  • The signature documents exactly which effects the function uses. A function with only MonadState Int m provably can't throw errors or do I/O.

Your own capability classes

The same idea makes IO code testable. Define a class for the effect, implement it for IO in production, and for a pure monad in tests:

import Control.Monad.State

class Monad m => MonadLogger m where
  logMsg :: String -> m ()

instance MonadLogger IO where
  logMsg s = putStrLn ("[log] " ++ s)

newtype TestM a = TestM (State [String] a)
  deriving (Functor, Applicative, Monad)

instance MonadLogger TestM where
  logMsg s = TestM (modify (++ [s]))

runTest :: TestM a -> (a, [String])
runTest (TestM m) = runState m []

processOrder :: MonadLogger m => Int -> m Int
processOrder qty = do
  logMsg ("processing " ++ show qty)
  let total = qty * 3
  logMsg ("total " ++ show total)
  return total

main :: IO ()
main = do
  _ <- processOrder 4
  print (runTest (processOrder 4))
[log] processing 4
[log] total 12
(12,["processing 4","total 12"])

deriving (Functor, Applicative, Monad) on a newtype uses the GeneralizedNewtypeDeriving extension (part of GHC2021) to reuse State's instances.

Alternatives

mtl style is the most common approach, but you'll also meet effect systems — libraries such as effectful, polysemy, and bluefin — which let you define and combine effects more freely, and the plain ReaderT pattern from the previous lesson. All three are used in production; learn mtl first, since the others build on the same ideas.