A Crash Course in Sequencing IO Functions


This is yoinked from an answer I gave on the Idris2 discord where someone was confused about using IO when there was multiple functions involved. I’ve slightly restructured it, but it’s still basically the Discord answer verbatim.

There’s only 2000 characters to use in Discord, so this is a very rough and quick introduction to IO, the ‘bind’ operator, and do notation. If I ever understand monads, I’ll try to write a more “proper” introduction.

IO, continuations, and values

IO () represents an I/O action which returns no value (e.g. putStrLn "Welcome!" only outputs the string Welcome!; it doesn’t also return some value for further computation):

printWelcome : IO ()
printWelcome = putStrLn "Welcome!"

This is not very exciting… So how can we sequence functions which do I/O? And how do we deal with functions that compute a value from that I/O?

IO and continuations

To combine two IO () actions, we use >>= (read “bind”). The type of >>= for IO is:

(>>=) : IO a -> (a -> IO b) -> IO b

This reads as: “If I have a value of type a from some I/O, and I know what to do with it (function from a to IO b), then I can just do that and get the result (IO b)”).

So to output two strings, we could write:

main : IO ()
main = putStrLn "Hello" >>= (\ _ => putStrLn "World!")

The anonymous function

\  _ => putStrLn "World!"

is the continuation of doing

putStrLn "Hello"

(And we discard its result using _).

IO, continuations, and values

If we instead have a value we care about, e.g. an IO String, we can describe what to do with that value in the continuation like we would with any anonymous function:

main : IO ()
main = getLine >>= (\ str => putStrLn ("Hello " ++ str))

And we can chain this as much as we want (indented for some readability):

main : IO ()
main = getLine >>=
         (\ str => putStrLn ("Hello " ++ str) >>=
           (\ _ => putStrLn "Welcome to Idris!" >>=
             (\ _ => putStrLn "Enjoy yourself!"

IO, continuations, values, and do-notation

Sequences of bind are almost entirely unreadable (and tedious to write). So we instead use do-notation:

main : IO ()
main = do s <- getLine
          _ <- putStrLn "Hello " ++ str
          _ <- putStrLn "Welcome to Idris!"
          putStrLn "Enjoy yourself!"

Which is equivalent to the verbose >>= notation, but looks much nicer and is easier to write! : )

All the other functions

Functions which don’t return IO can be used in combination with pure or let:

main : IO ()
main = do someVal <- pure someFunc
          let otherVal = otherFunc
          putStrLn ("Look ma: " ++ someVal ++ " and " ++ otherVal ++ "!")

(assuming the functions someFunc and otherFunc returned strings)

And there you have it!

That’s a crash-course in how you combine IO with functions, include non-IO computations, and sequence multiple things you want to do. Thanks for reading! : )

Thomas Ekström Hansen
Thomas Ekström Hansen
PhD student in Computer Science

My research interests include low-level programming, type systems, and formal methods.