Error Handling
Lean uses types to make failure explicit. No null pointers, no exceptions—justOption and Except.
The Problem with Null
In languages with null, any value might secretly be null. This leads to runtime crashes. Lean's type system prevents this entirely.
1-- In many languages: head([]) crashes or returns null2-- In Lean: we must handle the empty case34def safeHead (xs : List α) : Option α :=5 match xs with6 | [] => none7 | x :: _ => some x89#eval safeHead [1, 2, 3] -- some 110#eval safeHead ([] : List Nat) -- noneOption: Maybe There's a Value
Option α represents a value that might be missing:
1inductive Option (α : Type) where2 | none : Option α -- No value3 | some : α → Option α -- Has a value45-- Examples6def maybeNumber : Option Nat := some 427def noNumber : Option Nat := none89-- Access with pattern matching10def showOption (opt : Option Nat) : String :=11 match opt with12 | none => "Nothing"13 | some n => s!"Got {n}"Working with Option
Instead of pattern matching everywhere, Option provides convenient methods for common operations. These methods make working with optional values concise and composable.
1-- getD: get with default2#eval (some 5).getD 0 -- 53#eval none.getD 0 -- 045-- map: transform inner value6#eval (some 5).map (· * 2) -- some 107#eval none.map (· * 2) -- none89-- bind: chain optional operations10def half (n : Nat) : Option Nat :=11 if n % 2 == 0 then some (n / 2) else none1213#eval (some 10).bind half -- some 514#eval (some 7).bind half -- none15#eval none.bind half -- nonebind is also written as >>=. It chains operations that might fail—if any step fails, the whole chain fails.Option in Practice
1-- Find an element2def find (p : α → Bool) : List α → Option α3 | [] => none4 | x :: xs => if p x then some x else find p xs56#eval find (· > 5) [1, 3, 7, 2] -- some 77#eval find (· > 10) [1, 3, 7, 2] -- none89-- Dictionary lookup10def lookup (key : String) (pairs : List (String × α)) : Option α :=11 match pairs with12 | [] => none13 | (k, v) :: rest => if k == key then some v else lookup key rest1415def env := [("x", 1), ("y", 2)]16#eval lookup "x" env -- some 117#eval lookup "z" env -- noneDesigning with Error Types
Use Option when the absence of a value is expected and unremarkable. Use Except when you need to explain why something failed.
Except: Errors with Context
Except ε α is like Option but carries error information:
1inductive Except (ε α : Type) where2 | error : ε → Except ε α -- Failed with error3 | ok : α → Except ε α -- Succeeded with value45-- Example: division with error message6def divide (x y : Nat) : Except String Nat :=7 if y == 0 8 then Except.error "Division by zero"9 else Except.ok (x / y)1011#eval divide 10 2 -- Except.ok 512#eval divide 10 0 -- Except.error "Division by zero"Working with Except
Like Option, Except supports mapand bind operations. These let you transform success values or chain fallible operations without manual error propagation.
1-- Pattern matching2def showResult (r : Except String Nat) : String :=3 match r with4 | .error e => s!"Error: {e}"5 | .ok n => s!"Success: {n}"67-- map and bind work similarly to Option8#eval (Except.ok 5 : Except String Nat).map (· * 2) -- ok 109#eval (Except.error "oops" : Except String Nat).map (· * 2) -- error "oops"1011-- Chain fallible operations12def smallEnough (n : Nat) : Except String Nat :=13 if n > 100 then .error s!"{n} is too large"14 else .ok n1516def compute (x y : Nat) : Except String Nat := do17 let quotient ← divide x y18 smallEnough quotient1920#eval compute 10 2 -- Except.ok 521#eval compute 10 0 -- Except.error "Division by zero"22#eval compute 1000 2 -- Except.error "500 is too large" if n < 0 then .error "negative" for n : Nat is dead code — a Nat is never negative, so the branch can never be taken. Lean will not warn you. Validate the things that can actually go wrong, and if you need negatives, use Int.Do Notation for Error Handling
The do notation makes chaining fallible operations clean:
1-- Without do notation (messy)2def processWithoutDo (x y z : Nat) : Except String Nat :=3 match divide x y with4 | .error e => .error e5 | .ok a => match divide a z with6 | .error e => .error e7 | .ok b => .ok (b * 2)89-- With do notation (clean)10def processWithDo (x y z : Nat) : Except String Nat := do11 let a ← divide x y12 let b ← divide a z13 return b * 21415#eval processWithDo 100 5 2 -- ok 2016#eval processWithDo 100 0 2 -- error "Division by zero"17#eval processWithDo 100 5 0 -- error "Division by zero"← syntax extracts values from Except(or Option). If extraction fails, the whole doblock short-circuits and returns the error.Custom Error Types
1-- Define specific errors2inductive ValidationError where3 | tooShort : Nat → ValidationError4 | invalidChar : Char → ValidationError5 | reserved : String → ValidationError67def validateUsername (name : String) : Except ValidationError String :=8 if name.length < 3 then9 .error (.tooShort name.length)10 else if name.any (· == ' ') then11 .error (.invalidChar ' ')12 else if name == "admin" then13 .error (.reserved name)14 else15 .ok nameConverting Between Option and Except
1-- Option to Except2def optionToExcept (opt : Option α) (err : ε) : Except ε α :=3 match opt with4 | none => .error err5 | some a => .ok a67-- Except to Option (loses error info)8def exceptToOption (ex : Except ε α) : Option α :=9 match ex with10 | .error _ => none11 | .ok a => some a1213-- Built-in the other way round:14#check @Except.toOption -- Except ε α → Option α1516-- Going Option → Except has no single built-in name, because you must17-- supply the error. The idiom is a one-liner:18def Option.toExcept (opt : Option α) (err : ε) : Except ε α :=19 opt.elim (.error err) .ok2021#eval (some 5).toExcept "missing" -- Except.ok 522#eval (none : Option Nat).toExcept "missing" -- Except.error "missing"Deep Dive: Why No Exceptions?
Exceptions in languages like Java or Python are problematic:
- They're invisible in function signatures
- They bypass the type system
- They make reasoning about code harder
- They complicate theorem proving
With Option and Except, failure is:
- Visible in the type:
Except Error Value - Checked by the compiler
- Composable with
donotation - Provable (you can prove code handles all errors)
The Escape Hatches: get!, panic!, and IO Exceptions
Everything above keeps failure in the types. Lean also gives you ways out of that discipline. They are legitimate in the right place — and a design smell everywhere else.
1-- get! : "I know this is some". Crashes at runtime if it is not.2#eval (some 5).get! -- 53-- #eval (none : Option Nat).get! -- PANIC: value is none45-- panic! : deliberately crash with a message. Requires Inhabited.6def mustBePositive (n : Int) : Nat :=7 if n > 0 then n.toNat else panic! s!"expected positive, got {n}"89-- IO has real exceptions, because the outside world does10#eval show IO Unit from do11 try12 let _ ← IO.FS.readFile "does-not-exist.txt"13 IO.println "read it"14 catch e =>15 IO.println s!"caught: {e}"1617-- throw raises one18#eval show IO Unit from do19 try20 throw (IO.userError "something went wrong")21 catch e =>22 IO.println s!"caught: {e}"get! and panic! do not make your function partial in the type system — the signature still claims it always returns a value. What actually happens is that panic! returns the Inhabited default and logs to stderr, so a proof about the function sees a plausible-but-wrong value rather than a crash. That mismatch between what runs and what you proved is exactly why these should be rare.Option/Except for anything a caller might reasonably handle; IO exceptions for genuine environment failures (missing files, network); and panic! only for invariants you believe are impossible, as documentation of that belief.Common Patterns
1-- Fallbacks: <|> takes the first success (this is Alternative.orElse)2#eval (some 3) <|> (some 5) -- some 33#eval (none : Option Nat) <|> (some 5) -- some 545-- Written out, that is:6def orElse (x : Option α) (y : Option α) : Option α :=7 match x with8 | some a => some a9 | none => y1011#eval orElse none (some 5) -- some 512#eval orElse (some 3) (some 5) -- some 31314-- Collect successes, ignore failures15def filterMap (f : α → Option β) (xs : List α) : List β :=16 xs.filterMap f1718def parseNat? (s : String) : Option Nat := s.toNat?1920#eval ["1", "hello", "3"].filterMap parseNat? -- [1, 3]2122-- Require all to succeed23def sequence (xs : List (Option α)) : Option (List α) :=24 xs.mapM id2526#eval sequence [some 1, some 2, some 3] -- some [1, 2, 3]27#eval sequence [some 1, none, some 3] -- noneCreate a validator that accepts ages 0–120 and returns a helpful error otherwise.
1def validateAge (n : Nat) : Except String Nat :=2 if n <= 120 then .ok n else .error "age out of range"34#eval validateAge 25 -- ok 255#eval validateAge 200 -- error "age out of range"