Skip to content

IO: Talking to the Outside World

I hope you have by now come to appreciate that monads are a useful tool for expressing lots of different types of side effects of a computation: exception handling, non-determinism, stateful computations, and many more. However, all the monads we have discussed so far do nothing but make expressing such computations more convenient. We could have implemented exception handling, non-determinism, stateful computations, etc. using pure functions. In fact, that's exactly what we did when we defined the Monad instances for Maybe, lists, and State. Performing I/O, like reading files or accessing a database over a network, is a different story. These are true side effects that cannot be modelled using pure functions.

In its youth, Haskell used a different mechanism to build a bridge between pure functions and the dirty but necessary world that surrounds our program. The fact that we can use monads to model side effects, including I/O, was discovered only later, and then this tool introduced into Haskell to support I/O developed a life of its own and found applications in modelling all kinds of side effects.

So IO is the "original monad" in Haskell in a sense, the one that started it all. The reason why we discuss it second to last is that it is useful to understand the State monad in order to grasp how the IO monad ticks.

Let's figure out what we want to capture. A computation of type IO a should produce a return value of type a, but it can read and write files, send messages across the network or format our hard drive along the way. What we have learned so far is that monads provide us with a purely functional approach to model side effects. What is the effect of reading and writing files, sending messages across the network or moving a robot arm? Each of these operations changes the world that surrounds our program. The robot arm may have pointed up before our program ran, and now the arm points down. Or there may have been files on our hard drive before we chose to format it, and now there are none. Each of these operations changes the state of the world around our program. This sounds like something we could model using the State monad. All we need is a data type RealWorld that contains information about the state of every single atom in the universe. In addition to returning a value of type a, a computation of type IO a then takes the original state of the universe as input and, along with its return value, returns an updated state of the universe. We simply have

type IO a = State RealWorld a

Of course, there is no way to implement a type RealWorld that really stores the state of the entire universe. What we can do is to define a type RealWorld that doesn't really store anything but logically represents the state of the universe. It's a useful abstraction to model the idea of threading the state of the whole universe through our program. And that's what the I/O monad is: a state monad whose state is the whole world.

If you dig deep into the standard library, you will find that IO isn't defined via the State monad but via a more low-level cousin of State called State#. RealWorld is an actual type in the Haskell standard library—you can Hoogle it—and the real definition of IO using the State# monad does the same thing in spirit as our made-up definition given above.

Since RealWorld and State# are built deeply into Haskell's runtime system, they don't have implementations in pure Haskell that we could discuss, nor can we provide implementations of return and (>>=) for the IO monad in pure Haskell. That's exactly the point: computations in the IO monad involve real side effects and thus cannot be expressed using pure functions. However, the analogy to the State monad goes a long way to understand how return and (>>=) behave.

Remember, return x is supposed to take a pure value x and decorate it using our monad. In the case of the State monad, return x is a stateful function, only it neither reads nor writes the state, it leaves the state alone. But that's allowed. Mapping this to IO, we want return x to be an action in the IO monad that returns x and leaves the state of the world alone. It reads no files, launches no nukes, and doesn't call your grandmother. It simply returns x without performing any I/O.

A computation x >>= f in the state monad evaluates x, including the changes to the state that this makes. Then we apply f to the value returned by x. What f does with this value may depend on the state left behind by x and may modify this state some more. Thus, it is important that f runs after x. In the IO monad, if x writes some file and f reads the same file, we expect that f sees what x has written because in the state of the world left behind by x, the file exists and contains the content written by x. Thus, the real effect of using the IO monad to sequence effectful functions is to make sure that they happen in the correct order.

Pragmatically, you don't need to understand any of this. You could just as well approach the IO monad as this magical black box, where a computation like

f :: Int -> IO ()
f x = do
    let y = 2 * x
    putStrLn $ "The value of y is " ++ show y

behaves exactly like code in imperative programming languages: the statements are executed in sequence, and each statement sees the side effects of the previous statements.

This is how most introductions to Haskell approach the IO monad, but in my opinion, this only helps to create the impression that monads are deeply magical and hard to understand. In reality, monads are simply a purely functional means to describe computations with side effects, and viewing the IO monad as a state monad with the whole world as its state is exactly the right abstraction to model computations that create effects that can be seen outside of our program, on our hard drive, in our bank account or in the silly dance performed by a robot.

So now that we know what the IO monad is, what functions are there to perform I/O? Obviously, the list is looooong because interacting with every single peripheral device attached to our computer is a form of I/O, be it a graphics card, a network card, a hard drive, a logic board, a mouse or keyboard, etc, etc, etc. I will discuss only the I/O functions that you need to get going. In particular, these functions are enough to complete your Haskell programming project.