Skip to content

Do Notation

Before we continue our exploration of monads, let's make working with them more convenient. The Maybe monad is great for (simple) exception handling, but it's not particularly convenient to work with. In Java, I can write things like:

int foo(int x) {
    int a = f(x);
    try {
        int b = g(a);
        int c = h(x, a, b);
        return k(b, c);
    }
    catch (Exception e) {
        return handle_exception(x, a);
    }
}

This says that we assume that f cannot fail. Then we use g to produce b from a, which may fail, as can our application of h to x, a, and b, and our application of k to b and c. If something goes wrong, it must somehow have been caused by the values of x and a, so we pass x and a to our exception handler.

Here's what this would look like using our Maybe monad right now. Prepare for some ugliness:

foo :: Int -> Maybe Int
foo x = let a = f x
        in  ( g a >>= \b -> h x a b
                  >>= \c -> k b c
            ) `catch` handler x a

Okay, I admit this is surprisingly compact, but it sure doesn't make it easy to see the logic. Let's take it apart to convince ourselves that it really matches the logic of the Java code. First our function types:

f cannot fail. Its type is

f :: Int -> Int

g, h, and k can fails, as can the exception handler. Their types are

g       :: Int ->               Maybe Int
h       :: Int -> Int -> Int -> Maybe Int
k       :: Int -> Int ->        Maybe Int
handler :: Int -> Int ->        Maybe Int

Given the argument x, we call f x and assign the result to the local variable a. That's the same as int a = f(x). Next we build the catch-try block. Let's add proper parentheses, which makes the code even uglier but reveals its structure better:

foo :: Int -> Maybe Int
foo x = let a = f x
        in  ( g a >>= ( \b -> h x a b
                          >>= ( \c -> k b c
                              )
                      )
            ) `catch` handler x a

First we call g a. We could try to assign its result to another local variable b, but this wouldn't quite do what we want. This variable would have type Maybe Int because that's the return type of g a. In Java, g(a) produces an int; the information that g(a) may fail is passed along behind the scenes in the form of exceptions. We want to do the same in Haskell, using our (>>=) operator of the Maybe monad to pass along the information that things may fail, and working with plain old Ints as long as things don't fail.

The way to do this is to build a function that expresses the computation that follows after the call g a. We want this function to have access to the return value of g a, so we bind the return value of g a to the argument of this function: g a >>= (\b -> ...). Given the logic of (>>=), this never calls (\b -> ...) if g a fails to produce a value. Just as in Java, the computation inside the try block aborts, and control transfers to the exception handler. If g a produces some value Just t, then (>>=) assigns t (without the Just!) to the argument b of (\b -> ...) and evaluates (\b -> ...). Since b is the name of the function argument of (\b -> ...), it is accessible everywhere inside (\b -> ...), so it effectively acts like a local variable. That's exactly the same as in Java, where the code that comes after int b = g(a) has access to b, only the Haskell version is much uglier.

Let's continue. Our function (\b -> ...) should do exactly the same as the remainder of the try block in Java. So the next thing we should do is call h x a b. This may also fail, but if it doesn't, we want to assign the result to a variable c. We use the same trick again: we bind the return value to the argument of a function (\c -> ...) that represents whatever comes after h x a b. This successor function calls k b c, which is the return value of the whole computation if k b c succeeds.

We can think about the sequencing of steps in Java as nested scopes: int a = f(x) introduces the variable a. This variable is visible everywhere after this statement. int b = g(a) introduces the variable b. After this statement, both a and b are visible. int c = h(x, a, b) introduces the variable c. After this statement, a, b, and c are visible. And, of course, x is visible throughout the body of foo because it's foo's argument. Our nesting of anonymous functions in Haskell achieves exactly the same effect.

If anything goes wrong in our pipeline g a >>= \b -> h x a b >>= \c -> k b c, then the result is Nothing, and catch invokes handler x a to recover from the exception, just as the Java code would jump to the catch block as soon as an exception is raised by one of the function calls.

Now let's make our Haskell code prettier. We can in fact implement foo like this:

foo :: Int -> Maybe Int
foo x = do let a = f x
           ( do b <- g a
                c <- h x a b
                k b c
           ) `catch` (
             handler x a
           )

Except for sprinkling in some do and let keywords, this looks remarkably like the Java code. Also note the use <- as the assignment operator instead of =. This will be important shortly.

So what does do do? It's a syntactic construct that allows us to pretend we're imperative programmers when we're "inside" a monad—any monad, not just Maybe.

Intuitively, you should read the sequence of statements inside a do-block as a sequence of steps to be executed in order, just as you would in an imperative program. So, here we say, let a be the result of f x. Then execute the code that comes after it, which mimics our try-catch block. This code has another do-block inside it, but that's fine: do-blocks can nest. The inner do-block starts by assigning g a to b, then h x a b to c, and finally evaluates k b c. Whatever k b c returns is the return value of the entire inner do-block because k b c is the last expression in this do-block. Note that there's no return statement. Indeed, as we have seen, return means something very different in a monad than in imperative languages.

If anything goes wrong inside the inner do-block, that is, if any of the functions g, h or k returns Nothing, then the value of the whole do-block is Nothing, so catch runs the exception handler.

Now here's the part that makes do-notation useful. Inside the do-block, the decoration of values provided by the monad is passed around completely behind the scenes, just as exceptions in Java are completely invisible to us as long as they don't happen. They're a hidden layer under the surface. So the assignment b <- g a assigns an Int to b, not a Maybe Int. The assignment operator <- inside a do-block peels off the decoration. The decoration is quietly passed along to the next statement. The manner in which this happens is determined by the (>>=) operator of the monad. We'll discuss this in the next section.

So what exactly are the rules for do-blocks? Here they are: Every do-block consists of a sequence of statements. Each of these statements can be of one of three forms, where we assume that the do-block expresses a computation in some monad m:

  • It can be a let-block that defines some local variables. These local variables are accessible anywhere inside the let-block and after it. Note that I said let-block not let-statement, because it is really a block. In our example above, we had let a = f x, but we can build let-blocks that assign more variables, as in

    let a = f x
        b = f' a x
    

    You can think about let-blocks as locally "turning off" the monad. The code inside the let-block is pure code, just as the variable definitions after let in a let ... in ... expression or in a where block. That's also why we use = for "assignments" inside a let-block. The <- assignment operator used in a do-block is reserved for peeling off the decoration from decorated values, and this works only outside of let-blocks.

  • It can be an assignment pat <- expr. In this case, expr must have the type m a for some type a, that is, it is an expression that evaluates to a value decorated using the monad m. <- peels off the decoration and assigns the value of type a to pat.

    Note that I wrote pat, not var, to suggest that pat can be any pattern. It doesn't have to be a variable. For example, if we had an expression bar x y z of type m (Int, Bool, Double), then we can write (i, _, d) <- bar x y z. Pattern matching then ignores the middle Bool component of the result of bar x y z, and it assigns the Int and Double components to i and d.

    While we may use pattern matching in pat <- expr statements, this pattern matching must not fail.1 If pat is a single variable, then pattern matching does not fail. If the function returns a tuple and we match its components to variables or wildcards, as we did in (i, _, d) <- bar x y z, this also cannot fail. When matching against data constructors of types with multiple data constructors or against specific values, those patterns can fail and should not be used.

    Any variable bound as part of matching against the pattern pat is accessible anywhere after the statement pat <- expr.

  • It can be a single expression expr of some type m a. If this is the last expression in the do-block, then the value of this expression is the value of the whole do-block. If it isn't, then this expression is evaluated only for its side effect, and its return value is ignored. For example, we could have a function in the Maybe monad that fails if its argument is odd:

    failIfOdd :: Int -> Maybe ()
    failIfOdd x = if odd x then Nothing else Just ()
    

    We can then write something like

    multiplyIfEven :: Int -> Int -> Maybe Int
    multiplyIfEven x y = do
        failIfOdd x
        return (x * y)
    

    Here, we call failIfOdd on x. If x is odd, this fails, returns Nothing, so the whole do-block fails and produces Nothing. Otherwise, we want to return x * y. Note that we do need to use return here, and I suspect this is also the reason why this method of the Monad class was named return. The reason is that the last expression of a do-block must have type m a, where m is our monad and a is the return type of the do-block. Here, we want the do-block to have type Maybe Int. The expression x * y has type Int. To turn it into a Maybe Int, we use return, because for the Maybe monad, return = Just.

There's one more important rule: The last statement in a do-block must always be an expression. It can't be a let-block or an assignment pat <- expr because the do-block must produce a value and the only statement that provides a value is a statement of the form expr. We're still programming functionally after all; do-notation only provides the illusion of imperative programming.

Another important point to make is that case and if then else are expressions in Haskell, and of course nothing prevents us from calling the function we're defining using do-notation recursively. Thus, we have the exact same control flow constructs available inside do as we have when writing pure functions. For example, here is a function that prints "Hello world!" several times, using the IO monad, which we will discuss shortly:

GHCi
>>> :{
  | greetRepeatedly :: Int -> IO ()
  | greetRepeatedly reps = do
  |     if reps == 0 then
  |         return ()
  |     else do
  |         putStrLn "Hello world!"
  |         greetRepeatedly (reps - 1)
  | :}
>>> greetRepeatedly 10
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!

In this example, the whole do-block consists of a single if then else expression. Since greetRepeatedly should produce a value of type IO (), this is the value that both branches of the if then else expression need to produce. If reps == 0, we return (). This doesn't really do anything. It simply wraps the provided value () in the IO monad, just as return () in the Maybe monad would produce the value Just (). If reps /= 0, then the else-branch is itself a do-block that first prints "Hello world!" using putStrLn "Hello world!" and then calls greetRepeatedly recursively, with argument reps - 1, that is, this recursive call takes care of the remaining reps - 1 times that "Hello world!" should be printed. The return value of this inner do-block is the value of its last statement, that is, the value produced by greetRepeatedly (reps - 1). This obviously has the desired type IO () because that's what greetRepeatedly returns.

I used this example only to demonstrate that we can use standard control-flow constructs inside do-blocks. The seasoned Haskell programmer would use multiple equations to implement this function:

GHCi
>>> :{
  | greetRepeatedly :: Int -> IO ()
  | greetRepeatedly 0 = return ()
  | greetRepeatedly reps = do
  |     putStrLn "Hello world!"
  |     greetRepeatedly (reps - 1)
  | :}
>>> greetRepeatedly 10
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!

Even if a function decorates its return value using some monad and uses do-notation, it is still a normal function, and all the constructs we have discussed in the previous chapters can be used to define such a function.


  1. There are some monads, such as Maybe, that are instances of the MonadFail class, a subclass of Monad. These are monads that can cope with failure or, in the case of Maybe, model the very essence of failure. A failed pattern match is such a failure. Thus, in monads that are instances of MonadFail, we can use patterns that may fail to match. In other monads, we can't.