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 * xFor the same input it produces the same result:
square 5
-- 25Mathematically, 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 :: StringA 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 StringThe 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 ().
| Expression | Type | Meaning when the action is performed, if applicable |
|---|---|---|
"Hello" | String | An ordinary string value |
42 :: Int | Int | An ordinary integer value |
getLine | IO String | Read 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 resultA 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.hsAlternatively, compile and run an executable:
ghc -o hello Main.hs
./helloBoth 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
GoodbyeThe 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 bFor IO, this specializes to:
(>>=) :: IO a -> (a -> IO b) -> IO bThe 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 bIt 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, AliceIn this transcript, Alice is the line entered by the user.
The important binding is:
name <- getLineThe 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:
- The question is printed.
getLinereads the input.- The returned string becomes the argument to
\name -> .... - 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 greetingThe three lines play different roles:
name <- getLinebinds the result of the input action for subsequent statements.let greeting = "Hello, " ++ namedefines an ordinary local string expression.putStrLn greetingsupplies 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 aFor IO, its specialization is:
pure :: a -> IO aFor example, pure 3 :: IO Int is an action that produces the result 3 without an external input/output operation. Likewise:
pure "Hello" :: IO Stringdoes 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 block | Purpose | Example |
|---|---|---|
let x = expression | Define a local value without performing IO | let x = 3 + 5 |
x <- action | Bind the result when the action is executed | name <- getLine |
pure x | Supply an action whose result is x | pure 42 :: IO Int |
Inside do, a let statement has no in:
do
let x = 3 :: Int
print xAn ordinary let expression uses in to specify its body:
let x = 3 :: Int in x + 1
-- 4The 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 -> Stringshow 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:
42The 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"Helloprint "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 aThe annotation in the program selects Int as the target:
readMaybe "20" :: Maybe Int
-- Just 20
readMaybe "abc" :: Maybe Int
-- Nothingcase 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:
| Stage | Operations | Role |
|---|---|---|
| Obtain input | getLine | Interact with the outside world |
| Process values | greet, readMaybe, arithmetic, show | Compute ordinary results |
| Produce output | putStrLn | Perform 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
sayHelloThis 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 xIt 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
CharlieCombine 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.
| Expression | Result type in these examples | Behavior |
|---|---|---|
map putStrLn names | [IO ()] | Construct a list of output actions |
sequence_ (map putStrLn names) | IO () | Combine those actions in list order |
mapM_ putStrLn names | IO () | 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 contentThe 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 -> Stringmaps 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 -> aWe could then try to hide input inside a definition that appears pure:
name :: String
name = unsafeExtract getLineThe 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
| ExpressionLayout normally supplies the grouping. For example:
do
name <- getLine
let greeting = "Hello, " ++ name
putStrLn greetingThe 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 countA 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 IntShow solution
getLine :: IO String
putStrLn "Hello" :: IO ()
pure 3 :: IO Int -- with the supplied annotationgetLine 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 :: StringThe 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
CThe 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
| Concept | Syntax or type | Purpose |
|---|---|---|
| IO action | IO a | Describe an IO computation that can produce a |
| Entry point | Conventionally main :: IO () | Supply the action performed by the runtime |
| Print a string | putStrLn :: String -> IO () | Write text followed by a newline |
| Read a line | getLine :: IO String | Obtain text from standard input |
| Print a value | print :: Show a => a -> IO () | Write its Show representation and a newline |
| Monadic binding | x <- action inside do | Bind an action’s result for subsequent statements |
| Local definition | let x = expression inside do | Name a value without performing IO |
| Sequencing | first >> second | Sequence actions and discard the first result |
| Bind | action >>= continuation | Feed an action’s result to the next computation |
| Supply a result | pure x | Produce x in the IO context |
| Read a file | readFile :: FilePath -> IO String | Obtain file contents using lazy string IO |
| Write a file | writeFile :: 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.