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 type2def add (x y : Nat) : Nat := x + y34-- Impure: returns IO5def greet : IO Unit := IO.println "Hello!"67-- The type tells you: this function does I/OKey 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
lean
1-- Print to console2def sayHello : IO Unit := do3 IO.println "Hello, World!"45-- Read from console6def askName : IO String := do7 IO.print "Enter your name: "8 IO.getLine910-- Put it together11def interactive : IO Unit := do12 IO.print "What's your name? "13 let name ← IO.getLine14 IO.println s!"Hello, {name.trim}!"File I/O
lean
1-- Read entire file2def readConfig : IO String := do3 IO.FS.readFile "config.txt"45-- Write to file6def writeLog (msg : String) : IO Unit := do7 IO.FS.writeFile "log.txt" msg89-- Append to file10def appendLog (msg : String) : IO Unit := do11 let handle ← IO.FS.Handle.mk "log.txt" .append12 handle.putStrLn msgCommand Line Arguments
lean
1def main (args : List String) : IO Unit := do2 IO.println s!"Got {args.length} arguments"3 for arg in args do4 IO.println s!" - {arg}"Composing IO Actions
lean
1-- Sequence with do notation2def program : IO Unit := do3 IO.println "Step 1"4 IO.println "Step 2"5 IO.println "Step 3"67-- Pass results between actions8def compute : IO Nat := do9 IO.println "Computing..."10 let input ← IO.getLine11 let n := input.trim.toNat!12 IO.println s!"Got {n}"13 return n * 2Error Handling in IO
IO operations can fail. Use try/catch:
lean
1def safeRead (path : String) : IO String := do2 try3 IO.FS.readFile path4 catch e =>5 IO.println s!"Error reading file: {e}"6 return ""78-- Or return an option/result9def tryReadFile (path : String) : IO (Option String) := do10 try11 let content ← IO.FS.readFile path12 return some content13 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 form2def main : IO Unit := do3 IO.println "Hello, World!"45-- With command line arguments6def main (args : List String) : IO Unit := do7 match args with8 | [] => IO.println "No arguments"9 | _ => IO.println s!"Arguments: {args}"1011-- With exit code12def main : IO UInt32 := do13 IO.println "Running..."14 return 0 -- 0 = successUseful IO Utilities
lean
1-- Get current time2def showTime : IO Unit := do3 let now ← IO.monoMsNow4 IO.println s!"Milliseconds since start: {now}"56-- Environment variables7def getEnv : IO Unit := do8 let path ← IO.getEnv "PATH"9 match path with10 | none => IO.println "PATH not set"11 | some p => IO.println s!"PATH = {p}"1213-- Current working directory14def showCwd : IO Unit := do15 let cwd ← IO.currentDir16 IO.println s!"Current directory: {cwd}"1718-- List directory contents19def listDir : IO Unit := do20 let entries ← IO.FS.readDir "."21 for entry in entries do22 IO.println entry.fileNameDeep 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"34-- It's just a value. The printing happens when main runs:5def main : IO Unit := actionThis 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 function2def double (n : Nat) : Nat := n * 234-- Use pure functions inside IO5def pureInIO : IO Unit := do6 let x := double 21 -- Pure computation7 IO.println s!"Result: {x}"89-- But you can't use IO inside pure functions10-- def bad : Nat := do11-- let x ← IO.getLine -- Error! Can't do IO here12-- 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 actions2#eval IO.println "This prints in the editor"34-- Useful for quick tests5#eval do6 IO.println "Line 1"7 IO.println "Line 2"8 return 42