How to Use Monads without Understanding Them

06 April 2022

To clarify from the start, this article is meant for people who write Haskell, because it covers do notation, the most common way of writing monadic code. I wanted to write it, because I believe you can write monadic code without understanding monads.

Instead, you need to know 2 things:

In this article, I will cover the rules.

The examples below mostly use the IO monad, but the rules apply to all monads.

Rule 1: Don't Mix Your Monads

You can't use functions that return different monads in the same do block.

We have these 2 functions:

getArgs :: IO [String]
headMaybe :: [a] -> Maybe a

This won't work:

main = do
  cliArgs <- getArgs
  firstArg <- headMaybe cliArgs

it doesn't work, because getArgs returns an IO value, while headMaybe returns a Maybe value. Keep reading to see a way around this.

Rule 2: Use <- to access values in a monad

We have the following code:

import qualified Data.Text as T

main = do
  cliArgs <- getArgs
  let
    values = fmap (T.unpack . T.toTitle . T.pack) cliArgs
  putStrLn $ "Titled args: " ++ (show values)

getArgs gives you a list of the arguments passed on the command line and has the type IO [String].

By doing cliArgs <- getArgs we have access to the [String] part, which is the list of arguments.

Here is another example with a different function:

main = do
  fileContents <- readFile "words.txt"
  let
    charCount = length fileContents
  putStrLn $ "File words.txt has " ++ (show charCount) ++ " chars."

readFile reads the contents of a file and has the type FilePath -> IO String. So the function takes a FilePath and returns IO String. Again, by using <- we get access to String which represents the contents of the file.

Put Values

Sometimes, we have the opposite situation though. Namely, we want to put a value in the monad. For example:

main = do
  putStrLn "Hello! What's your name?"
  putStr "Name: "
  name <- getLine
  putStrLn $ "Hello, " ++ name

putStrLn prints a string and has the type String -> IO (). As you can see, it returns IO (). In this case, IO doesn't give us a meaningful value, just (), so we don't use <-. We just put the function call on its own line.

Here is another example:

import qualified Data.Text as T

main = do
  fileContents <- readFile "words.txt"
  let
    fileWords = words fileContents
    titledWords = fmap (T.unpack . T.toTitle . T.pack) fileWords
    newFileContents = unwords titledWords
  writeFile "titled-words.txt" newFileContents

The relevant function here is writeFile. As the name suggests, it writes a string to a file and it has the type FilePath -> String -> IO (). IO () again as the return type.

As a general rule, when we have IO a for any meaningful value of a, it makes sense to use <-. When we have IO (), it doesn't.

Rule 3: Use let for pure code

In several examples above, I used let. If it's not clear from the examples, let is like an escape hatch. That means that it allows you to write:

All your Haskell programs are an example of the first case. You use IO to get something from the command line or from a file. Then, you process that something and you use IO again to write it to a file or to the terminal.

Btw, this is diferent from let ... in ..., which is an expression that you use outside a do block.

Pure Code

The most simple example would be:

main = do
  cliArgs <- getArgs
  let 
    magicEffect = doMagic cliArgs
  putStrLn $ "Result of magic: " ++ magicEffect

where doMagic has type [String] -> String. As you can see, there's no IO in the type.

Monadic Code

Here's an example where let allows us to nest a do block that uses a different monad:

main = do
  cliArgs <- getArgs
  let
    result = do
      head_ <- headMaybe cliArgs
      int <- readMaybe head_ :: Maybe Int
      return $ show $ int ^ 2
  putStrLn $ fromMaybe "N/A" result

This function gets the arguments passed on the command line.

Then, it extracts the first one and converts it to an Int. Then, it does the totally useless thing of calculating its square, because I couldn't think of a more meaningful example. All this is happening in the Maybe monad.

Finally, we print the result, having "N/A" as a default, in case one of the functions inside the nested do block returned Nothing.

Multiple lets

You can use more than one let block and intermingle it with monadic code. For example:

main = do
  putStrLn "Hello, user! What's your name?"
  putStr "Enter name: "
  name <- getLine
  let
    boldedName = "**" ++ name ++ "**"
  putStrLn $ "Greetings, " ++ boldedName ++ "! Nice chatting with you."
  putStrLn "Why do you want to learn Haskell?"
  putStrLn "a) For fun"
  putStrLn "b) For profit"
  putStrLn "c) Both"
  putStr "Enter choice: "
  choice <- getLine
  let
    answer =
      case choice of
        "a" -> "Aha. Fun is your middle name."
        "b" -> "No time for games, eh?"
        "c" -> "Are you a Libra?"
        _ -> "So you don't like to play by the rules, ... or you were just confused?"
  putStrLn answer
  putStrLn "Good luck! Bye!"

Rule 4: The final expression in the do block must yield a value of your monad

If you look at all the examples I've given so far, you'll notice that all of them end with a function that returns IO. That's because the do block must yield a value of our monad.

We have 2 options here:

We've seen examples of the first case, so let's look at an example for the second one:

titledHead :: [String] -> Maybe String
titledHead items = do
  ourHead <- headMaybe items
  let
    titledHead = T.unpack . T.toTitle . T.pack $ ourHead
  return titledHead

Our function deals with the happy path. If items has at least one item, it's extracted as ourHead. We can apply the transformations we want and we get titledHead. titledHead has the type String, but our function must return Maybe String, so we use return to wrap our String.

In case, it's not obvious, you can use other expressions inside a do block, as long as they yield the right value:

main = do
  putStrLn "Do you like pizza?"
  putStrLn "a) Yes"
  putStr "Choice: "
  choice <- getLine
  if choice == "a" 
  then
    putStrLn "Benvenuto!"
  else
    putStrLn "We don't want you here."

Or:

main = do
  putStrLn "Pick a number between 1 and 3"
  putStr "Number: "
  number <- getLine
  case number of
    "1" -> putStrLn "First!"
    "2" -> putStrLn "Second!"
    "3" -> putStrLn "What was left."
    _ -> putStrLn "Typo?"

To summarize, inside a do block, you can:

PS: Not all monads support all of these operations. For example, you can't put a value in the Reader monad.