λ Fun With Functions

Monads: The Why, the What For, and Especially the It's Like X, But!

Posted on October 6, 2026

It’s Like X, But!

Every explanation of monads seems to reach for an analogy at some point. They’re like boxes, but. They’re like promises, but. They’re like burritos, but (really). The trouble is that the “but” is always doing most of the work, so instead of another analogy this post starts from something every programmer already knows and works up from there.

Dispelling Some Of The Mystique

With things like monads there’s a tendency in some parts of the internet to attribute some kind of incredible complexity to them. When all they really are in Haskell is just a simple interface (in object-oriented terms) with some laws (think rules) about how the functions implemented for that interface should behave.

The example I like to use is the Eq typeclass, which is how equality with == is handled in Haskell. It defines two functions and 5 laws which will make sense to anyone who has done even the most basic of algebra like the symmetry law of x == y being the same as y == x.

One Thing After Another

Pretty much every language has this concept:

doThingA();
doThingB();

If that wasn’t hugely clear, it’s the concept of running one bit of code after another bit of code. In most languages that’s done by putting them on separate lines or separating them with semicolons. Haskell does this too, but not in the way pretty much everything else does:

doThings1 :: IO ()
doThings1 = do
  putStrLn "Doing thing A."
  putStrLn "Doing thing B."

The do is the hint that something slightly different is going on here. We could write the same thing slightly longer hand:

doThings2 :: IO ()
doThings2 = do
  _ <- putStrLn "Doing thing A."
  _ <- putStrLn "Doing thing B."
  pure ()

doThings1 and doThings2 are the same bit of code, the first just employs a little syntactic sugar to make things look a bit nicer.

If we look at the type of putStrLn it looks like this:

putStrLn :: String -> IO ()

So given a String it returns an IO of unit. In this situation IO is the monad we’re operating in, IO being how Haskell describes things that talk to the outside world, may go bang, or would otherwise breach referential transparency.

What’s Between The Lines

The Monad typeclass has a method >>=, otherwise known as bind, which is what gets invoked “between the lines” of do notation. Its signature looks like this:

(>>=) :: Monad m => m a -> (a -> m b) -> m b

The Monad m => part just means this works for anything for which there’s a monad instance. If this was an object-oriented language it would implement a Monad interface or something along those lines.

So given an m of a, and a function that takes an a and returns an m of b, we get back an m of b.

Since do notation is just syntactic sugar we can remove it entirely:

doThings3 :: IO ()
doThings3 =
  putStrLn "Doing thing A."
    >>= (\_ -> putStrLn "Doing thing B.")
    >>= (\_ -> pure ())

Note how the _ <- lines from doThings2 have turned into the \_ -> lambdas here. What this shows is that running one thing after another isn’t some special case baked into the compiler, it’s a plain old function that a programmer can use, and more interestingly can define for their own types. With IO this means that actions can be stitched together into bigger actions before eventually being run, with the failure handling held in one place.

So What Does That Buy Us?

This is all well and good, but what does it give us over good old imperative code? Say we had a list of user IDs and wanted to query some database for the usernames of those users. We might have some code like this, with a pretend database lookup:

listOfUserIds :: [Int]
listOfUserIds = [1, 2, 3]

lookupUser :: Int -> IO String
lookupUser userId = pure ("User" ++ show userId)

usernames :: [IO String]
usernames = fmap lookupUser listOfUserIds

lookupUser takes an Int and produces the username as an IO action. But usernames is a really unwieldy thing to hold, a list of actions that haven’t run yet, and we can’t print out all the names without some fiddly looping of our own.

Enter the sequence function:

sequence :: (Traversable t, Monad m) => t (m a) -> m (t a)

Squint past the Traversable for a moment and read t as a list. This turns a list of m of a into an m of a list of a. So in our case we can print out those usernames like so:

printOutUsernames :: IO ()
printOutUsernames = do
  names <- sequence usernames
  print names

Which outputs ["User1","User2","User3"]. If any of the lookups fails then the whole result fails, with no extra code needed to make that happen.

Mapping and then sequencing is so common that it has its own name, traverse, so the above could just as well be written as:

printOutUsernamesAgain :: IO ()
printOutUsernamesAgain = do
  names <- traverse lookupUser listOfUserIds
  print names

That Traversable constraint also means this isn’t limited to lists, the same function works over Maybe, Map, trees and plenty more.

Not Just IO

Since list is itself a monad, we can do some fun things with it too:

listOfListOfNumbers :: [[Int]]
listOfListOfNumbers = [[1, 2], [3, 4]]

sequenceAListOfLists :: IO ()
sequenceAListOfLists = print (sequence listOfListOfNumbers)

Which outputs [[1,3],[1,4],[2,3],[2,4]]. For lists “one thing after another” means “for every entry”, so sequencing gives us every combination.

This is the real payoff. sequence wasn’t written for IO, or for lists, it was written once for all monads. When code is expressed in terms of Monad rather than one particular type, it works for every type that comes along later, including ones that didn’t exist when the code was written. Which is also why the analogies never quite fit, a monad isn’t like any one X, it’s the shape that lots of different Xs share.

Try It Out

You can experiment with this in the Haskell playground here: Code In Haskell Playground