6 min read
On this page

Monads

Forget burritos. Forget warehouses. A Monad is a typeclass with two operations and three laws, and the only thing it actually adds over Applicative is the ability for a later step to depend on the value produced by an earlier step.

That sentence does the work of every metaphor anyone has ever invented. The rest is examples.

The typeclass

class Applicative m => Monad m where
  return :: a -> m a            -- same as pure; legacy name
  (>>=)  :: m a -> (a -> m b) -> m b

>>= is pronounced "bind". It takes a wrapped value m a and a function that, given the unwrapped a, produces a new wrapped value m b. The shape of "given the unwrapped a" is exactly what Applicative cannot do.

With Applicative, the second action is fixed before the first runs:

(+) <$> readLn <*> readLn   -- two reads no matter what

With Monad, the second action can be chosen based on the first:

do
  n <- readLn
  if n < 0 then pure 0 else readLn

That is the entire conceptual delta. Everything else — do-notation, monad transformers, MTL — is plumbing built on top.

What >>= does for specific types

This is where it stops being abstract.

Maybe: early exit

instance Monad Maybe where
  Nothing >>= _ = Nothing
  Just x  >>= f = f x

If the value is missing, the rest of the computation is skipped. If it is there, feed it to the next step:

lookup :: Eq k => k -> [(k, v)] -> Maybe v

lookupRoute :: String -> Maybe Int
lookupRoute name = do
  user <- lookup name users        -- Maybe User
  addr <- lookup user addresses    -- Maybe Address
  pure (zipCode addr)              -- Maybe Int

Three sequential lookups, each depending on the previous. Any Nothing short-circuits the whole chain. Without monadic do-notation you would be writing nested case expressions:

lookupRoute name =
  case lookup name users of
    Nothing -> Nothing
    Just user -> case lookup user addresses of
      Nothing -> Nothing
      Just addr -> Just (zipCode addr)

That is the value of >>= for Maybe: it collapses nested case-on-failure into a flat sequence.

Either: early exit with a reason

instance Monad (Either e) where
  Left e  >>= _ = Left e
  Right x >>= f = f x

Same shape as Maybe, but failure carries information:

data ParseError = NotANumber String | OutOfRange Int

parsePort :: String -> Either ParseError Int
parsePort s = do
  n <- maybe (Left (NotANumber s)) Right (readMaybe s)
  if n >= 1 && n <= 65535
    then Right n
    else Left (OutOfRange n)

The first failure wins, and you know what failed. This is the standard pattern for expected, recoverable errors in production Haskell — Mercury's banking codebase, the IOG/Cardano node, hasura's GraphQL engine all lean on Either-shaped monads (often via ExceptT) for domain errors.

List: nondeterminism

instance Monad [] where
  xs >>= f = concatMap f xs

Each element produces a list; results are concatenated. This is "try all the possibilities":

pairs :: [(Int, Int)]
pairs = do
  x <- [1, 2, 3]
  y <- [10, 20]
  pure (x, y)
-- [(1,10),(1,20),(2,10),(2,20),(3,10),(3,20)]

Pythagorean triples in three lines:

triples :: Int -> [(Int, Int, Int)]
triples n = do
  a <- [1..n]
  b <- [a..n]
  c <- [b..n]
  guard (a*a + b*b == c*c)
  pure (a, b, c)

Each <- "picks" a value, and the do-block is run for every combination. guard from Control.Monad prunes branches that fail a predicate. This is the same idea logic programmers use in Prolog, but mostly typed and free of cuts.

IO: real-world effects

main :: IO ()
main = do
  putStr "Name: "
  name <- getLine
  putStrLn ("Hello, " ++ name)

getLine :: IO String. <- runs the action and binds the result. Each step sequences after the previous one — that ordering is what makes IO sane. Desugared, it is:

main =
  putStr "Name: " >>= \_ ->
  getLine        >>= \name ->
  putStrLn ("Hello, " ++ name)

The >>= is the same operator. The behaviour is type-specific: for IO, it means "run the first action, then run the function on its result".

Do-notation is sugar

Every do-block desugars to >>= and >>:

do x <- m;  rest      ===   m >>= \x -> do rest
do m;       rest      ===   m >>  do rest
do let x = e; rest    ===   let x = e in do rest
do m                  ===   m

That is the entire transformation. Once you see it, you can read any do-block as a chain of binds. The advantage of do-notation is that nested binds get unbearable past three or four steps; the disadvantage is that beginners think do-notation is special syntax for "imperative Haskell". It is not. It is >>= rearranged.

The laws

Three laws. Like the Functor laws, the compiler does not check them; library authors and users assume them.

Left identity

return x >>= f  ==  f x

Wrapping a value and immediately binding is the same as just calling the function.

Right identity

m >>= return  ==  m

Binding to return does nothing.

Associativity

(m >>= f) >>= g  ==  m >>= (\x -> f x >>= g)

You can re-associate binds without changing meaning. This is what makes do-notation safe to refactor — pulling a sub-block into a helper does not change behaviour.

If a Monad instance breaks these, do-notation lies to you. The IO and Maybe and Either e instances are all lawful. So is the State monad. Some clever experimental monads break laws and pay the price.

Why this matters in practice

You will spend a lot of Haskell life inside monads. Database queries return IO (Either DbError a). HTTP handlers in servant run in Handler, which is ExceptT ServerError IO underneath. STM transactions run in STM. Parser combinators are monads. Test frameworks like hspec run in their own monad. The pattern of "do-notation, with <- for results, with the option to short-circuit or branch" recurs constantly.

The reason monads are worth learning rather than dodging: once you understand the abstraction, you can read code in any of these libraries the same way. The do-block in IO looks like the do-block in Parser looks like the do-block in STM. Different effects, same shape.

When Monad is the wrong tool

This is worth saying explicitly because the language nudges you toward Monad:

  • If your computation does not need to inspect a result before choosing the next step, Applicative is more flexible (works with Validation, easier to analyze).
  • If you only need a pure transformation, you do not need any of these — just plain functions.
  • If you need to combine multiple monadic effects (state + IO + errors), you are looking at monad transformers or an effect system, not a single monad. That is the subject of the next topic.

Common pitfalls

Treating do-notation as imperative code. It looks like Python, but the semantics are governed by the monad's >>=. In Maybe, a failed line skips the rest. In lists, a single line runs many times. The visual similarity to imperative code is a trap, not a feature.

Forgetting that pure / return is type-specific. pure 5 :: IO Int does no I/O. pure 5 :: [Int] is [5]. pure 5 :: Maybe Int is Just 5. Same expression, different meanings depending on the inferred type.

Using >>= when <$> would do. If you write m >>= \x -> pure (f x), that is just f <$> m. The compiler will not flag it; HLint will.

Stacking monads inside monads without transformers. IO (Maybe (Either Err a)) is technically fine but painful — every read of the inner value needs nested case or a tower of fmap. Monad transformers exist precisely for this.

Confusing Monad m => with "in the IO monad". A function with Monad m => works in any monad. A function with m ~ IO only works in IO. Beginners often write the second when they meant the first, then wonder why their code is hard to test.

Skipping the laws. Custom monads that break associativity will work in trivial examples and explode when refactored or used with monad transformers.

Key takeaways

  • A Monad adds one capability over Applicative: the next step can depend on the result of the previous step.
  • >>= is the bind operator; do-notation is sugar that desugars to chains of >>=.
  • The behaviour of >>= is per-type: short-circuit for Maybe, error-with-info for Either e, all-combinations for [], sequenced effects for IO.
  • Three laws — left identity, right identity, associativity — make refactoring do-blocks safe.
  • Pick the weakest abstraction that works: Functor < Applicative < Monad. Reaching for Monad when Applicative would do narrows the types your function accepts.
  • Do-notation is not imperative code. It is a chain of binds rendered to look readable. Understand the desugaring and you understand the language.