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, and(← IO.getStdin).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. Note: there is no bare IO.getLine —
6-- you read from a stream, and stdin is one.
7def askName : IO String := do
8 IO.print "Enter your name: "
9 ( IO.getStdin).getLine
10
11-- Put it together
12def interactive : IO Unit := do
13 IO.print "What's your name? "
14 let name ( IO.getStdin).getLine
15 IO.println s!"Hello, {name.trimAscii}!"

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.getStdin).getLine
11 let n := input.trimAscii.toString.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.trimAscii.toString.toLower
3
4-- IO wrapper
5def greetUser : IO Unit := do
6 IO.print "Name: "
7 let name ( IO.getStdin).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
15
16-- (These are three ALTERNATIVES — a file may only define main once.)
Lean accepts IO Unit, IO UInt32, and either of those taking List String. With IO Unit the exit code is 0 unless an uncaught exception escapes, in which case it is 1. Use IO UInt32 when you need to signal specific failures to a shell script or CI job.

IO, BaseIO, and EIO

IO is not primitive — it is one member of a small family, and you will see the others in signatures and error messages.

lean
1-- EIO ε α : an IO action that can fail with an error of type ε
2-- IO α = EIO IO.Error α ← the usual one
3-- BaseIO α = EIO Empty α ← cannot fail at all
4
5#check @IO.getStdin -- BaseIO IO.FS.Stream — reading stdin never fails
6#check @IO.FS.readFile -- ... : IO String — reading a file can
7
8-- A BaseIO action can be used anywhere an IO action is expected
9-- (it just never takes the failure path), but not the other way round.
💡
If a try/catchgives you "no exceptions to catch"-flavoured type errors, check whether the action is BaseIO — it is telling you the failure you are guarding against cannot happen.

Running External Commands

lean
1-- Run a command and capture its output
2#eval show IO Unit from do
3 let out IO.Process.output { cmd := "echo", args := #["hello"] }
4 IO.println s!"exit={out.exitCode} stdout={out.stdout.trimAscii}"
5
6-- Run and just wait for the exit code
7#eval show IO Unit from do
8 let code IO.Process.spawn { cmd := "echo", args := #["hi"] } >>= (·.wait)
9 IO.println s!"exited with {code}"

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 (readDir lives on FilePath, not IO.FS)
19def listDir : IO Unit := do
20 let entries (System.FilePath.mk ".").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 IOactions 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.getStdin).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.getStdin).getLine
4 let n := input.trimAscii.toString.toNat!
5 IO.println s!"Double is {n * 2}"