Module 5 · Lesson 2

The IO Monad

IO encapsulates side effects—file I/O, network requests, randomness—while keeping pure functions pure.

Why IO?

Pure functions always return the same output for the same input. But some operations are inherently impure:

  • Reading user input (different each time)
  • Writing to a file (changes external state)
  • Getting current time (depends on when called)
  • Making HTTP requests (network conditions vary)

The IO type marks functions that perform these effects:

lean
1-- Pure: no IO in type
2def add (x y : Nat) : Nat := x + y
3
4-- Impure: returns IO
5def greet : IO Unit := IO.println "Hello!"
6
7-- The type tells you: this function does I/O
Key Takeaway
If a function's return type includes IO, it might perform side effects. If it doesn't, it's guaranteed pure.

Basic IO Operations

Console I/O

Console input and output are the most common IO operations. Use IO.printlnto write a line, IO.print to write without a newline, andIO.getLine to read user input.

lean
1-- Print to console
2def sayHello : IO Unit := do
3 IO.println "Hello, World!"
4
5-- Read from console
6def askName : IO String := do
7 IO.print "Enter your name: "
8 IO.getLine
9
10-- Put it together
11def interactive : IO Unit := do
12 IO.print "What's your name? "
13 let name IO.getLine
14 IO.println s!"Hello, {name.trim}!"

File I/O

Reading and writing files is straightforward with the IO.FSnamespace. For simple cases, readFile and writeFilehandle the entire file at once.

lean
1-- Read entire file
2def readConfig : IO String := do
3 IO.FS.readFile "config.txt"
4
5-- Write to file
6def writeLog (msg : String) : IO Unit := do
7 IO.FS.writeFile "log.txt" msg
8
9-- Append to file
10def appendLog (msg : String) : IO Unit := do
11 let handle IO.FS.Handle.mk "log.txt" .append
12 handle.putStrLn msg

Command Line Arguments

When your Lean program is compiled and run from the command line, arguments passed to the executable are available as a list of strings in the main function.

lean
1def main (args : List String) : IO Unit := do
2 IO.println s!"Got {args.length} arguments"
3 for arg in args do
4 IO.println s!" - {arg}"

Composing IO Actions

lean
1-- Sequence with do notation
2def program : IO Unit := do
3 IO.println "Step 1"
4 IO.println "Step 2"
5 IO.println "Step 3"
6
7-- Pass results between actions
8def compute : IO Nat := do
9 IO.println "Computing..."
10 let input IO.getLine
11 let n := input.trim.toNat!
12 IO.println s!"Got {n}"
13 return n * 2

Keep a Pure Core

A common pattern is to keep business logic pure and use IO only for input/output. This keeps code testable and reusable.

lean
1-- Pure function
2def sanitize (s : String) : String := s.trim.toLower
3
4-- IO wrapper
5def greetUser : IO Unit := do
6 IO.print "Name: "
7 let name IO.getLine
8 let clean := sanitize name
9 IO.println s!"Hello, {clean}!"

Error Handling in IO

IO operations can fail. Use try/catch:

lean
1def safeRead (path : String) : IO String := do
2 try
3 IO.FS.readFile path
4 catch e =>
5 IO.println s!"Error reading file: {e}"
6 return ""
7
8-- Or return an option/result
9def tryReadFile (path : String) : IO (Option String) := do
10 try
11 let content IO.FS.readFile path
12 return some content
13 catch _ =>
14 return none
Unlike Except, IO exceptions are actual runtime exceptions. Use try/catch to handle them.

The main Function

Every Lean executable has a main function:

lean
1-- Simplest form
2def main : IO Unit := do
3 IO.println "Hello, World!"
4
5-- With command line arguments
6def main (args : List String) : IO Unit := do
7 match args with
8 | [] => IO.println "No arguments"
9 | _ => IO.println s!"Arguments: {args}"
10
11-- With exit code
12def main : IO UInt32 := do
13 IO.println "Running..."
14 return 0 -- 0 = success

Useful IO Utilities

lean
1-- Get current time
2def showTime : IO Unit := do
3 let now IO.monoMsNow
4 IO.println s!"Milliseconds since start: {now}"
5
6-- Environment variables
7def getEnv : IO Unit := do
8 let path IO.getEnv "PATH"
9 match path with
10 | none => IO.println "PATH not set"
11 | some p => IO.println s!"PATH = {p}"
12
13-- Current working directory
14def showCwd : IO Unit := do
15 let cwd IO.currentDir
16 IO.println s!"Current directory: {cwd}"
17
18-- List directory contents
19def listDir : IO Unit := do
20 let entries IO.FS.readDir "."
21 for entry in entries do
22 IO.println entry.fileName
Deep Dive: IO is Lazy

An IO value is a description of an action, not the action itself. The action only runs when the runtime executes it.

lean
1-- This doesn't print anything:
2def action : IO Unit := IO.println "Hello"
3
4-- It's just a value. The printing happens when main runs:
5def main : IO Unit := action

This is why you can pass IO actions around, store them in data structures, and compose them—they're just values.

Mixing Pure and Impure Code

lean
1-- Pure function
2def double (n : Nat) : Nat := n * 2
3
4-- Use pure functions inside IO
5def pureInIO : IO Unit := do
6 let x := double 21 -- Pure computation
7 IO.println s!"Result: {x}"
8
9-- But you can't use IO inside pure functions
10-- def bad : Nat := do
11-- let x ← IO.getLine -- Error! Can't do IO here
12-- x.length
💡
Keep as much logic as possible in pure functions. Use IOonly at the boundaries—reading input, writing output.

Running IO in #eval

lean
1-- #eval can run IO actions
2#eval IO.println "This prints in the editor"
3
4-- Useful for quick tests
5#eval do
6 IO.println "Line 1"
7 IO.println "Line 2"
8 return 42
Exercise: Read and Compute

Read a line from input, parse it as a Nat, and print double the value.

lean
1def readDouble : IO Unit := do
2 IO.print "Enter a number: "
3 let input IO.getLine
4 let n := input.trim.toNat!
5 IO.println s!"Double is {n * 2}"