I/O Operations in Haskell
Master input and output in Haskell - the IO monad, do notation, reading stdin, file reading and writing, formatted output, and I/O error handling with GHC
Input and output are where Haskell’s purity meets the messy real world. In most languages, printing a line or reading a file is just another statement. In Haskell, every action that touches the outside world is captured by a type: IO. This isn’t bureaucracy — it’s what lets the compiler guarantee that a “pure” function never secretly reads a file or prints to the screen.
Because Haskell is purely functional, a value of type IO a is not the result of an effect — it is a description of an effect that produces an a when the runtime executes it. You build these descriptions with functions like putStrLn, getLine, and readFile, then combine them using do notation or the bind operators (>> and >>=). Only the action bound to main is actually run.
This tutorial goes beyond the simple putStrLn you saw in Hello World. You’ll read input from the terminal, write and read files, format output with printf, and handle I/O errors as ordinary values. Along the way, notice how pure functions and impure actions stay cleanly separated — the type system enforces the boundary for you.
Formatted Output
Haskell offers several ways to produce output. putStr writes a string as-is, putStrLn adds a newline, and print uses a type’s Show instance. For C-style formatting, Text.Printf provides a type-safe printf.
Create a file named formatted_output.hs:
| |
Unlike C, printf in Haskell is polymorphic in its return type: it can produce an IO () (printing) or a String (via Text.Printf.printf used as String). Here it prints, and the compiler checks that the format specifiers match the argument types.
Reading Input from stdin
getLine :: IO String reads a single line from standard input. Because it returns an IO action, you extract its result inside a do block with the <- bind arrow. To turn text into a number, use read, guided by a type annotation.
Create a file named read_input.hs:
| |
Note the two binding styles: <- pulls a value out of an IO action, while let binds the result of a pure expression (no IO involved). Mixing them up is one of the most common beginner errors, and the type checker will catch it.
Writing and Reading Files
The Prelude gives you three convenient whole-file functions: writeFile (create or overwrite), appendFile (add to the end), and readFile (read the entire contents lazily). Each has an IO type, so file access is explicit in the signature.
Create a file named write_file.hs:
| |
Processing File Data with Pure Functions
The idiomatic Haskell pattern is to keep I/O at the edges and do the real work in pure functions. Here we read a file, parse it with pure list operations, then use mapM_ to run an IO action for each element.
Create a file named process_file.hs:
| |
Notice how lines, map, read, sum, and maximum are all pure — the only impurity is the surrounding readFile, writeFile, and putStrLn. This separation makes the parsing logic trivial to test in isolation.
Handling I/O Errors
Files might not exist and disks fill up, so I/O can fail. Haskell models failures as exceptions, but Control.Exception.try lets you capture them as an ordinary Either value — turning an exception into data you can pattern match on.
Create a file named io_errors.hs:
| |
The type annotation :: IO (Either IOException String) tells try which exception type to catch. Because the failure is now just a Left value, control flow stays explicit and the program keeps running instead of crashing.
Running with Docker
Pull the official image once, then run each example. The stdin example uses -i so the container can receive piped input.
| |
Expected Output
formatted_output.hs
No newline here. Now a newline.
[1,2,3]
Name: Alice, Age: 30
Pi is approximately 3.1416
Hex: ff, Zero-padded: 00042
read_input.hs (with Alice and 30 piped in)
What is your name?
How old are you?
Hello, Alice!
Next year you will be 31
write_file.hs
File contents:
Line 1: Hello from Haskell
Line 2: Appended text
process_file.hs
Numbers: [10,20,30,40]
Sum: 100
Maximum: 40
Doubled:
20
40
60
80
io_errors.hs
Error: could not read the file (it may not exist).
The program continues normally after handling the error.
Key Concepts
IO ais a description, not a result — building an IO action doesn’t run it; only the action wired tomain(and what it calls) executes.<-vslet— use<-to bind a value out of an IO action (name <- getLine) andletto name a pure expression (let age = read ageStr).- Purity stays at the edges — parse and compute with pure functions (
lines,map,sum), and keepreadFile/writeFile/putStrLnat the boundary. - Whole-file helpers —
writeFileoverwrites,appendFileextends, andreadFilereads lazily; all three carryIOin their types so effects are visible. printfis type-safe —Text.Printfchecks format specifiers against argument types at compile time, unlike C’s variadic version.mapM_runs an action per element — it sequences IO effects over a list and discards the results, ideal for printing each item.- Errors as values —
tryconverts an exception into anEither, letting you pattern match on failure instead of crashing. - Lazy
readFile— the file is read on demand as the resulting String is consumed, which is memory-friendly for large files but means the handle stays open until fully read.
Comments
Loading comments...
Leave a Comment