Part 10 of Haskell from First Principles. Continue from Part 9: Applicative Functors and Monads.

The first nine lessons developed pure functions, types, recursion, algebraic data types, higher-order functions, and the Functor, Applicative, and Monad interfaces. We can now use those ideas to write programs that read input, print output, and work with files.

How can a pure functional language interact with a changing external world? Haskell represents those interactions using IO. This lesson connects the meaning of IO a to the syntax of real programs: main, do, <-, local definitions, parsing, and action sequencing.

Each example defining main is an independent program. Replace the contents of Main.hs when moving to another complete example; do not paste several definitions of main into one file. Signatures of standard functions describe existing interfaces rather than declarations to add to your file. Code labeled as an invalid example deliberately fails to type-check.

GHCi can also execute IO actions entered at its prompt. That behavior is different from merely defining a binding in a source file, and we will distinguish the two below. Shell commands belong in your terminal; output and explanatory equations use text blocks.

10.1 The fundamental problem: Pure values and external input

Recall a pure function:

square :: Int -> Int
square x = x * x

For the same input it produces the same result:

square 5
-- 25

Mathematically, squaring means . Haskell’s Int is bounded, so this implementation can overflow for sufficiently large inputs; within its representable range the example agrees with that mathematical operation. Using Integer instead would give unbounded integer arithmetic.

Now consider reading a name from the keyboard. We might initially imagine a definition with this type:

readName :: String

A pure expression of type String denotes a string value. Reading from standard input is a computation that depends on an external interaction: the user may enter different text each time it is performed. A String type alone does not represent that operation.

Printing has a similar distinction. Producing a string value is different from making text appear on a terminal. The latter has an observable effect outside the expression being evaluated.

Haskell distinguishes ordinary result types, such as String and Int, from IO action types, such as IO String and IO Int. Both actions and ordinary results are Haskell values, but their types tell us which role they play.

10.2 Understanding IO a

Two standard interfaces are:

getLine :: IO String
putStrLn :: String -> IO ()

getLine :: IO String

getLine is an IO action. When performed successfully, it reads a line from standard input and produces the text as a String, without the terminating newline.

It does not itself have type String. Compare:

name    :: String
getLine :: IO String

The first describes an ordinary string result. The second describes an action that can obtain such a result through an external interaction.

putStrLn :: String -> IO ()

putStrLn accepts a string and produces an IO action. For example:

putStrLn "Hello" :: IO ()

Performing that action writes the string followed by a newline. Evaluating a definition that names the action is different from performing the action.

What does () mean?

() is the name of the unit type and also its sole total, ordinary value:

() :: ()

As with other Haskell types, an expression of this type can still diverge or raise an exception. The one-value description concerns successful, total results.

An action of type IO () does not provide a useful payload beyond unit when it completes successfully. That says nothing about how much it does externally: printing a line, writing a file, or performing many interactions can all have result type IO ().

ExpressionTypeMeaning when the action is performed, if applicable
"Hello"StringAn ordinary string value
42 :: IntIntAn ordinary integer value
getLineIO StringRead a line and produce its text
putStrLn "Hi"IO ()Print a line and produce unit
print (42 :: Int)IO ()Print a value’s representation and produce unit

Unannotated numeric literals such as 42 are polymorphic; the annotation in the table selects Int explicitly.

The distinction to keep in view is:

a       -- a result value
IO a    -- an IO action that can produce such a result

A useful analogy: A recipe

Think of an IO action as a recipe describing an interaction. Naming the recipe does not carry it out:

greetingAction :: IO ()
greetingAction = putStrLn "Hello"

This definition does not print merely because the binding exists. The output occurs when the action is performed as part of the program’s IO computation. The same action value can be used more than once, just as a recipe can be carried out more than once.

10.3 The main action: A program’s entry point

An ordinary executable conventionally defines main :: IO (). Here is a complete Main.hs:

module Main where
 
main :: IO ()
main = putStrLn "Hello, world!"

When the program runs, the runtime performs the action denoted by main. Its output is:

Hello, world!

There is no separate sequence of top-level commands. The source file defines a value named main, and that value describes the program’s IO computation.

More generally, Haskell permits an entry point of type IO a; its result is discarded. IO () is the usual choice because an executable communicates through its effects rather than a returned Haskell value.

Run your first program

With GHC installed, run the file from your terminal:

runghc Main.hs

Alternatively, compile and run an executable:

ghc -o hello Main.hs
./hello

Both approaches run the same greeting action.

You can also load it in GHCi and enter main:

ghci> :load Main.hs
ghci> main
Hello, world!

GHCi performs an IO expression entered at its prompt. A command such as :type main only inspects the type; it does not run the greeting.

Why not main :: String?

This is an invalid executable entry point:

main :: String
main = "Hello, world!"

The right-hand side is a valid string value, but an executable’s main must denote an IO action. A string does not tell the runtime to write itself to standard output.

10.4 Sequencing IO actions with do

To print several messages in order, compose the actions with do notation:

main :: IO ()
main = do
  putStrLn "Hello"
  putStrLn "Welcome to Haskell"
  putStrLn "Goodbye"

The output is:

Hello
Welcome to Haskell
Goodbye

The indentation groups the three actions into one expression. The do block is itself an action of type IO ().

Is do an imperative sequence of statements?

It resembles one syntactically, but its meaning comes from monadic composition. Recall the general bind signature from Part 9:

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

For IO, this specializes to:

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

The resulting action performs the first action and passes its result to a continuation that supplies the next action.

When the first result is not needed, use sequencing with >>:

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

It preserves the sequencing behavior while discarding the first result. Consequently, these are alternative definitions of the same two-message program:

main :: IO ()
main = do
  putStrLn "Hello"
  putStrLn "Goodbye"
main :: IO ()
main = putStrLn "Hello" >> putStrLn "Goodbye"

For IO, this composition controls the order of the observable operations. It does not mean that every pure expression in the program must be evaluated eagerly in source order.

10.5 Reading user input: The meaning of <-

Here is an interactive greeting program:

main :: IO ()
main = do
  putStrLn "What is your name?"
  name <- getLine
  putStrLn ("Hello, " ++ name)

One possible interaction is:

What is your name?
Alice
Hello, Alice

In this transcript, Alice is the line entered by the user.

The important binding is:

name <- getLine

The right-hand side has type IO String. Within the remainder of the do block, name has type String. When the combined action runs, getLine is performed, and its result is supplied under that local name.

This is an immutable binding, not a mutable assignment and not a general operation for removing IO from a value. Its scope is the continuation expressed by the rest of the block.

The equivalent code using bind

The same program can be written as:

main :: IO ()
main =
  putStrLn "What is your name?" >>
    (getLine >>= \name ->
      putStrLn ("Hello, " ++ name))

When this combined action is performed:

  1. The question is printed.
  2. getLine reads the input.
  3. The returned string becomes the argument to \name -> ....
  4. The continuation supplies the greeting action, which is then performed.

This is the same bind interface used for Maybe and Either. Their instances give it different meanings: Maybe propagates absence, Either propagates an error, and IO composes external interactions and their results.

10.6 The difference between let, <-, and pure

Consider this complete program:

main :: IO ()
main = do
  name <- getLine
  let greeting = "Hello, " ++ name
  putStrLn greeting

The three lines play different roles:

  • name <- getLine binds the result of the input action for subsequent statements.
  • let greeting = "Hello, " ++ name defines an ordinary local string expression.
  • putStrLn greeting supplies the output action in the sequence.

What if we use let instead of <-?

This is an invalid example:

main :: IO ()
main = do
  let name = getLine
  putStrLn ("Hello, " ++ name)

Here name has type IO String, because let gives the action another name. It does not perform the input operation and bind its returned string. The concatenation operator expects a string argument, so "Hello, " ++ name has a type mismatch.

A let binding can name any kind of value, including an IO action. Calling it a pure binding describes the act of defining a name, not a restriction that its value must lack an IO type.

What does pure do?

Recall the general interface:

pure :: Applicative f => a -> f a

For IO, its specialization is:

pure :: a -> IO a

For example, pure 3 :: IO Int is an action that produces the result 3 without an external input/output operation. Likewise:

pure "Hello" :: IO String

does not itself print the string. Compare:

pure "Hello"     :: IO String
putStrLn "Hello" :: IO ()

The first action provides a string as its result; the second writes a line and provides unit.

GHCi adds an extra behavior: after performing an IO expression, it normally displays a non-unit result that has a Show instance. Entering pure "Hello" :: IO String at its prompt may therefore display "Hello". That output comes from GHCi displaying the result, not from pure printing it. A compiled program does not automatically display every action’s result.

Form inside an IO do blockPurposeExample
let x = expressionDefine a local value without performing IOlet x = 3 + 5
x <- actionBind the result when the action is executedname <- getLine
pure xSupply an action whose result is xpure 42 :: IO Int

Inside do, a let statement has no in:

do
  let x = 3 :: Int
  print x

An ordinary let expression uses in to specify its body:

let x = 3 :: Int in x + 1
-- 4

The two forms express the same local-definition idea within different grammar rules.

10.7 show, print, and putStrLn

The Show interface from Part 7 supplies:

show :: Show a => a -> String

show is pure: it produces a textual representation without printing it.

show (42 :: Int)
-- "42"

By contrast:

print :: Show a => a -> IO ()

produces an action that prints a value using its Show representation. For example:

main :: IO ()
main = print (42 :: Int)

prints:

42

The relationship is:

print x = putStrLn (show x)

This equation explains the existing function; it is not a new definition to add to your file.

An important distinction for strings

These actions produce different output:

putStrLn "Hello"
Hello
print "Hello"
"Hello"

print includes the quotation marks because the Show String representation is a Haskell string literal. It also represents special characters using escapes where appropriate. putStrLn writes the string’s actual characters and appends a newline.

This makes print convenient for inspecting values while debugging, and putStrLn convenient for human-facing messages.

10.8 A complete interactive program

Now combine input, pure processing, and output. This program asks for a name and an age, parses the age, and displays either an answer or a parsing message.

Save this independent example as Main.hs:

module Main where
 
import Text.Read (readMaybe)
 
greet :: String -> String
greet name = "Hello, " ++ name ++ "!"
 
main :: IO ()
main = do
  putStrLn "What is your name?"
  name <- getLine
 
  putStrLn (greet name)
 
  putStrLn "Enter your age:"
  ageText <- getLine
 
  case (readMaybe ageText :: Maybe Int) of
    Nothing ->
      putStrLn "Invalid integer."
 
    Just age -> do
      let nextAge = age + 1
      putStrLn
        ("Next year, you will be "
          ++ show nextAge ++ ".")

One possible interaction is:

What is your name?
Alice
Hello, Alice!
Enter your age:
20
Next year, you will be 21.

Follow the type flow

greet :: String -> String is pure. The input action getLine has type IO String, while the local result bound to ageText has type String.

The next operation is pure parsing:

readMaybe :: Read a => String -> Maybe a

The annotation in the program selects Int as the target:

readMaybe "20" :: Maybe Int
-- Just 20
 
readMaybe "abc" :: Maybe Int
-- Nothing

case inspects this ordinary Maybe Int value. Each branch must supply a compatible result type; here both supply IO () actions. In the Just branch, age is an ordinary Int, so age + 1 and show nextAge are pure expressions. The nested do groups the local definition and output action.

Parsing and application validation are distinct. This small example accepts any text that parses as an Int, including negative numbers; its later Int arithmetic is bounded and can overflow. A real age check can be a separate pure function, and Integer can be used when unbounded integer arithmetic is appropriate.

readMaybe handles whether the supplied string parses. It does not catch IO failures from getLine, such as end-of-file. A result such as Maybe Int describes the parsing step rather than every possible failure in the whole program.

Keep the core computations pure

The structure is:

StageOperationsRole
Obtain inputgetLineInteract with the outside world
Process valuesgreet, readMaybe, arithmetic, showCompute ordinary results
Produce outputputStrLnPerform an observable interaction

Pure functions can be tested or reused without performing terminal interaction. IO connects those computations to the external world. This is a common design for Haskell programs.

10.9 IO actions are values and can be reused

An IO action can be named and used repeatedly:

sayHello :: IO ()
sayHello = putStrLn "Hello"
 
main :: IO ()
main = do
  sayHello
  sayHello
  sayHello

This prints Hello three times. Sharing the action value does not merge its three occurrences in the IO sequence into one execution.

The same point matters for input:

readAction :: IO String
readAction = getLine
 
main :: IO ()
main = do
  x <- readAction
  y <- readAction
  print (x, y)

The action is performed twice, so the program reads two lines. readAction = getLine names an input computation; it does not read once and cache the resulting string.

Compare this independent program:

main :: IO ()
main = do
  x <- getLine
  print x
  print x

It reads one line and prints the same resulting string twice. The local name x refers to the result, whereas readAction in the previous example refers to an action.

This distinction is compatible with purity. Reusing an action value means reusing the same description of an interaction, not promising that every execution receives the same response from the world.

10.10 How to execute a list of IO actions

Suppose we want to print three names:

map putStrLn ["Alice", "Bob", "Charlie"]

Recall the relevant types:

map :: (a -> b) -> [a] -> [b]
putStrLn :: String -> IO ()

Here the result type b is IO (). Consequently, the whole map expression has type [IO ()]: a list of actions. Constructing that list does not perform the actions.

This is therefore an invalid executable entry point:

main = map putStrLn ["Alice", "Bob", "Charlie"]

The result has a list type instead of the required IO action type. We need to combine the listed actions into one action.

Sequence the actions

The standard function sequence_, specialized to lists and IO, has this type:

sequence_ :: [IO a] -> IO ()

It performs the actions in list order and discards their results:

main :: IO ()
main = sequence_ (map putStrLn ["Alice", "Bob", "Charlie"])

The output is:

Alice
Bob
Charlie

Combine mapping and sequencing

mapM_ expresses that common pattern directly. Its list-and-IO specialization is:

mapM_ :: (a -> IO b) -> [a] -> IO ()

The same complete program can be written:

main :: IO ()
main = mapM_ putStrLn ["Alice", "Bob", "Charlie"]

The underscore indicates that the individual result values are discarded. The actions’ effects are still performed.

sequence_ and mapM_ have more general standard types that work with other suitable contexts and foldable structures. The specializations above show exactly what we need here.

ExpressionResult type in these examplesBehavior
map putStrLn names[IO ()]Construct a list of output actions
sequence_ (map putStrLn names)IO ()Combine those actions in list order
mapM_ putStrLn namesIO ()Map to actions and sequence them

The first constructs a collection of descriptions. The other two construct a single action that performs the described interactions when executed.

10.11 Working with files

The standard string-based file interfaces include:

readFile :: FilePath -> IO String
writeFile :: FilePath -> String -> IO ()

In these interfaces, FilePath is a type synonym for String. The IO distinction is the same as for terminal input and output.

Write a file

This independent Main.hs writes a small demonstration file:

main :: IO ()
main = do
  writeFile "message.txt" "Hello from Haskell!\n"
  putStrLn "File written."

When performed, writeFile creates message.txt in the program’s current working directory, or replaces its existing contents. The next action prints a confirmation after the write action completes successfully.

Read a file

After creating that demonstration file, replace the program with:

main :: IO ()
main = do
  content <- readFile "message.txt"
  putStrLn content

The action readFile "message.txt" has type IO String. Within the rest of the block, content has type String.

The file text includes the newline written by the first program. putStrLn appends another newline; use putStr content instead if you want to write the characters without adding one.

Lazy reading and IO failures

The standard readFile uses lazy IO. Its returned string can be consumed incrementally, and reading the remaining contents may happen as that string is demanded. Binding content does not guarantee that the entire file has already been read and closed. This matters for resource lifetimes and for when a read error becomes visible; strict interfaces and explicit handle management are useful in larger programs.

File operations can also fail, for example because a file is missing or access is denied. IO String does not promise that an action will successfully return a string. IO exceptions are a separate mechanism from an explicit result type such as Maybe or Either.

We will return to the relationship between lazy values and resource use when studying evaluation in more detail.

10.12 A deeper explanation: Why does IO preserve purity?

A pure function such as:

greet :: String -> String

maps an ordinary string to another ordinary string. An IO-producing function such as:

putStrLn :: String -> IO ()

maps a string to an action value. The external output is associated with performing that action, rather than occurring merely because a pure expression naming it is evaluated.

This separation allows ordinary expressions to remain referentially transparent while the composed IO computation performs effects. Replacing a named action by its definition preserves the action being described; it does not require separate executions of that action to receive identical input.

An illustrative mathematical model

One way to picture an action is as a transformation of the world:

In this picture, an action takes the current world, supplies a result, and supplies an updated world. This is an explanatory model, not the literal Haskell definition of IO. Actual IO also involves exceptions, concurrency, runtime behavior, and operating-system interactions that this simple picture leaves out.

Suppose the first action is modeled by:

and the continuation chooses another action from a result:

Then p >>= k can be pictured as:

The final result is . The updated world from the first operation supplies the starting point for the next. The model helps explain sequencing, including why changing the order of interactions can change a program’s behavior.

Why not simply extract a from IO a?

Imagine a general operation that safely performed an arbitrary IO action and returned its result as an ordinary pure value:

unsafeExtract :: IO a -> a

We could then try to hide input inside a definition that appears pure:

name :: String
name = unsafeExtract getLine

The apparent string value would now depend on an external interaction. This defeats the distinction encoded by the IO type.

Haskell’s safe IO interface therefore does not provide a general pure operation for executing arbitrary IO and extracting its result. Instead, we use that result within a continuation that remains in IO:

main :: IO ()
main = do
  name <- getLine
  putStrLn ("Hello, " ++ name)

The local name is an ordinary string, but the entire computation remains IO (). GHC exposes unsafe facilities that can bypass this discipline; they are outside the ordinary programming model developed here.

10.13 Conditional logic inside IO

As in Part 3, if is an expression, and its branches must have compatible types:

main :: IO ()
main = do
  putStrLn "Enter yes or no:"
  response <- getLine
 
  if response == "yes"
    then putStrLn "You answered yes."
    else putStrLn "You answered something else."

Both branches supply IO (), so the conditional supplies an IO () action. When that action is performed, the selected branch’s output occurs.

Perform an action only when a condition holds

An if expression still requires an else branch. We can use an action with no external effect for the other case:

main :: IO ()
main = do
  putStrLn "Enter yes or no:"
  response <- getLine
 
  if response == "yes"
    then putStrLn "Confirmed."
    else pure ()

Here pure () :: IO () supplies unit without performing an external operation, giving the branch the required type.

The helper when from Control.Monad captures this pattern:

import Control.Monad (when)
 
main :: IO ()
main = do
  putStrLn "Enter yes or no:"
  response <- getLine
  when (response == "yes") (putStrLn "Confirmed.")

unless, from the same module, performs its supplied action when the condition is false. These helpers construct conditional actions; they do not change the distinction between pure Boolean tests and IO operations.

10.14 The grammar of IO programs

The simplified grammar from Part 9 applies directly:

DoExpression :=
    do
      Statement
      Statement
      ...
      FinalExpression
 
Statement :=
    Pattern <- Expression
  | let Declarations
  | Expression

Layout normally supplies the grouping. For example:

do
  name <- getLine
  let greeting = "Hello, " ++ name
  putStrLn greeting

The first statement binds an IO result. The second introduces a local definition without in. The final expression supplies the IO result of the whole block.

The final expression determines the result type. For example:

askLength :: IO Int
askLength = do
  text <- getLine
  pure (length text)

length text is an ordinary Int. The final pure supplies an IO Int, so the whole block has type IO Int. To use it in a complete program, add:

main :: IO ()
main = do
  count <- askLength
  print count

A block cannot end with only a <- binding or a let statement; it needs a final expression of the appropriate contextual type.

The grammar permits patterns in bindings, but the examples here use variable patterns, which always match. Refutable patterns can require MonadFail, as discussed in Part 9. No special pattern-failure behavior is needed for name <- getLine.

do is also available with Maybe, Either, and lists. Its general purpose is contextual composition; IO is one instance with a particular meaning for sequencing.

10.15 Exercises

Attempt each exercise before opening its solution. The expected input/output behavior assumes the relevant reads and writes complete successfully.

1. Determine the expression types

What is the type of each expression?

getLine
putStrLn "Hello"
pure 3 :: IO Int
Show solution
getLine         :: IO String
putStrLn "Hello" :: IO ()
pure 3          :: IO Int     -- with the supplied annotation

getLine supplies a string when its input action succeeds. putStrLn "Hello" writes a line and supplies unit. The annotated pure 3 supplies an Int result without performing external IO. Without the annotation, pure 3 has a more general type involving Applicative and Num constraints.

2. Explain a type error

Why is this code invalid?

main = do
  let name = getLine
  putStrLn ("Hi " ++ name)
Show solution

let name = getLine binds name :: IO String. It names the action instead of binding the string produced by performing that action. Since ++ expects a list argument, it cannot concatenate the string "Hi " with an IO action.

Use <- to supply the result to the rest of the IO sequence:

main :: IO ()
main = do
  name <- getLine
  putStrLn ("Hi " ++ name)

Within the final statement, name :: String.

3. Rewrite bind using do

Rewrite this expression:

getLine >>= \name ->
  putStrLn ("Hello, " ++ name)
Show solution
do
  name <- getLine
  putStrLn ("Hello, " ++ name)

The remainder of the do block is the continuation that receives the input string. Both expressions have type IO () and describe the same sequence of interactions.

4. Count the input operations

How many lines does this program read?

main :: IO ()
main = do
  action <- pure getLine
  x <- action
  y <- action
  print (x, y)
Show solution

It reads two lines. The types reveal the two levels:

getLine      :: IO String
pure getLine :: IO (IO String)  -- in this IO do block
action       :: IO String
x, y         :: String

The outer action pure getLine simply supplies the action value getLine; it does not itself read a line. The binding names that returned action action. The next two statements each perform action, reading once for x and once for y.

pure does not recursively execute actions stored inside its result. Compare this program with reading once and printing the resulting string twice in Section 10.9.

5. Explain why map is not enough

Why does this expression alone not print all three strings?

map putStrLn ["A", "B", "C"]
Show solution

Its type is [IO ()]. It constructs a list containing three output actions but does not combine them into an executed IO sequence.

Use either of these as an independent complete program:

main :: IO ()
main = sequence_ (map putStrLn ["A", "B", "C"])
main :: IO ()
main = mapM_ putStrLn ["A", "B", "C"]

Both print:

A
B
C

The mapping builds the actions; sequencing arranges for them to be performed in list order when main runs.

6. Read and concatenate two lines

Write a program that reads two lines and prints their concatenation.

Show solution
main :: IO ()
main = do
  first <- getLine
  second <- getLine
  putStrLn (first ++ second)

Each <- binds an ordinary String. The concatenation is pure; the final putStrLn action writes the combined text and a newline.

For input lines Hello, and Haskell!, the output line is Hello, Haskell!. getLine removes each input line’s terminating newline, so this program does not insert a separator between the strings. If a separator is desired, add it explicitly to the concatenation.

IO reference

ConceptSyntax or typePurpose
IO actionIO aDescribe an IO computation that can produce a
Entry pointConventionally main :: IO ()Supply the action performed by the runtime
Print a stringputStrLn :: String -> IO ()Write text followed by a newline
Read a linegetLine :: IO StringObtain text from standard input
Print a valueprint :: Show a => a -> IO ()Write its Show representation and a newline
Monadic bindingx <- action inside doBind an action’s result for subsequent statements
Local definitionlet x = expression inside doName a value without performing IO
Sequencingfirst >> secondSequence actions and discard the first result
Bindaction >>= continuationFeed an action’s result to the next computation
Supply a resultpure xProduce x in the IO context
Read a filereadFile :: FilePath -> IO StringObtain file contents using lazy string IO
Write a filewriteFile :: FilePath -> String -> IO ()Create or replace a text file

Haskell represents external interactions explicitly through action types such as IO a. Pure functions can construct and transform those action values; the runtime performs the combined action described by main.

The next topic in the tutorial is lazy evaluation: how an infinite definition such as ones = 1 : ones can support take 5 ones, how sharing and thunks work, and what weak head normal form means. Pure evaluation can be lazy while the ordering of observable IO operations is controlled by their composition. The lazy readFile example shows why both aspects matter when reasoning about a program.

Adapted from the GPT-generated Block 10 tutorial supplied for this series, with worked exercise solutions and editorial clarifications.

← Previous: Applicative Functors and Monads · Series index