Do Notation
Do notation provides clean syntax for sequencing operations that have effects or might fail. It's essential for practical Lean programming.
The Problem: Nested Binds
Chaining operations that return Option or Exceptgets messy fast:
1def lookup (key : String) (env : List (String × Nat)) : Option Nat :=2 env.lookup key34-- Without do notation: deeply nested5def compute (env : List (String × Nat)) : Option Nat :=6 match lookup "x" env with7 | none => none8 | some x => match lookup "y" env with9 | none => none10 | some y => match lookup "z" env with11 | none => none12 | some z => some (x + y + z)Do Notation Cleans This Up
1-- With do notation: flat and readable2def compute' (env : List (String × Nat)) : Option Nat := do3 let x ← lookup "x" env4 let y ← lookup "y" env5 let z ← lookup "z" env6 return x + y + z78def env := [("x", 1), ("y", 2), ("z", 3)]9#eval compute' env -- some 6← extracts a value from Option. If any extraction fails (none), the whole block returnsnone.How Do Notation Works
do desugars to bind operations:
1-- This do block:2def example1 : Option Nat := do3 let x ← some 54 let y ← some 35 return x + y67-- Is equivalent to:8def example2 : Option Nat :=9 (some 5).bind fun x =>10 (some 3).bind fun y =>11 pure (x + y)1213-- Or using >>= operator:14def example3 : Option Nat :=15 some 5 >>= fun x =>16 some 3 >>= fun y =>17 pure (x + y)Key Constructs
let ← : Extract Value
The arrow ← unwraps a value from a monadic context. ForOption, it extracts the inner value fromsome; for none, it short-circuits the entire block.
1def safeDivide (x y : Nat) : Option Nat :=2 if y == 0 then none else some (x / y)34def calculation : Option Nat := do5 let a ← safeDivide 10 2 -- a = 56 let b ← safeDivide a 0 -- Fails! Returns none7 let c ← safeDivide b 1 -- Never reached8 return creturn : Wrap Value
The return keyword wraps a pure value back into the monadic type. For Option it produces some; for IO it produces a completed action.
1-- return wraps a pure value2def justFive : Option Nat := do3 return 5 -- same as: some 545-- Can be anywhere in the block6def example : Option Nat := do7 let x ← some 38 if x > 10 then9 return 0 -- Early return10 return x * 2let = : Pure Binding
When a computation is pure (no effects), use plain = instead of ←. This is just a normal variable binding.
1def example : Option Nat := do2 let x ← some 5 -- ← extracts from Option3 let y = x * 2 -- = is pure computation4 let z = y + 1 -- no Option involved5 return z -- some 11← for extracting from monads (Option,IO, etc.). Use = for regular assignments.Do Works with Many Types
Option
1def optionExample : Option String := do2 let x ← some "hello"3 let y ← some "world"4 return s!"{x} {y}"Except
1def divide (x y : Int) : Except String Int :=2 if y == 0 then .error "division by zero" else .ok (x / y)34def exceptExample : Except String Int := do5 let a ← divide 10 26 let b ← divide a 0 -- Fails with error message7 return bList (Non-determinism)
1-- For List, do represents all combinations2def pairs : List (Nat × Nat) := do3 let x ← [1, 2]4 let y ← [10, 20]5 return (x, y)67#eval pairs -- [(1, 10), (1, 20), (2, 10), (2, 20)]IO (Side Effects)
1def greet : IO Unit := do2 IO.println "What's your name?"3 let name ← IO.getLine4 IO.println s!"Hello, {name}!"Control Flow in Do Blocks
if-then-else
1def classify (n : Nat) : IO Unit := do2 if n == 0 then3 IO.println "zero"4 else if n < 10 then5 IO.println "small"6 else7 IO.println "large"for Loops
1def printNumbers : IO Unit := do2 for i in [1, 2, 3, 4, 5] do3 IO.println s!"Number: {i}"45def sumList (xs : List Nat) : IO Nat := do6 let mut sum := 07 for x in xs do8 sum := sum + x9 return sumwhile Loops
1def countdown : IO Unit := do2 let mut n := 53 while n > 0 do4 IO.println s!"{n}..."5 n := n - 16 IO.println "Liftoff!"Unless and When
unless and when provide cleaner conditional execution in do blocks:
1-- when: execute if condition is true2def maybeLog (verbose : Bool) (msg : String) : IO Unit := do3 when verbose do4 IO.println s!"[LOG] {msg}"56-- unless: execute if condition is false 7def requirePositive (n : Int) : IO Unit := do8 unless n > 0 do9 IO.println "Error: expected positive number"10 return11 IO.println s!"Processing {n}"1213-- Both are cleaner than if-then-else with Unit14#eval maybeLog true "Hello" -- prints "[LOG] Hello"15#eval maybeLog false "Hello" -- prints nothingMatch Inside Do
Pattern matching integrates naturally with do notation:
1def processResult (r : Except String Nat) : IO Unit := do2 match r with3 | .error e => IO.println s!"Error: {e}"4 | .ok n => 5 IO.println s!"Got value: {n}"6 let doubled := n * 27 IO.println s!"Doubled: {doubled}"89-- Match with ←10def processFile (path : String) : IO String := do11 let content ← IO.FS.readFile path12 match content.splitOn "13" with14 | [] => return "empty file"15 | first :: _ => return firstEarly Exit Patterns
Combine if with return to exit a block early. This keeps control flow clear without deeply nested branches.
1def ensurePositive (n : Int) : Except String Int := do2 if n <= 0 then3 return .error "expected positive"4 return .ok nDeep Dive: What is a Monad?
do notation works with any type that has aMonad instance. A monad provides:
1class Monad (m : Type → Type) where2 pure : α → m α -- Wrap a value3 bind : m α → (α → m β) → m β -- Chain operationsOption, Except,List, and IO are all monads.
Combining Different Monads
You can't mix monads directly in one do block:
1-- This doesn't work:2-- def bad : IO Nat := do3-- let x ← some 5 -- Option, not IO!4-- return x56-- Convert Option to IO by handling the failure:7def good : IO Nat := do8 let opt : Option Nat := some 59 match opt with10 | none => return 011 | some x => return xUse do notation to chain two Option computations.
1def parseNat (s : String) : Option Nat := s.toNat?23def sumTwo (a b : String) : Option Nat := do4 let x ← parseNat a5 let y ← parseNat b6 return x + y78#eval sumTwo "3" "4" -- some 79#eval sumTwo "3" "x" -- none