Pattern Matching
Pattern matching is the cleanest way to deconstruct data and express branching logic in Lean. It is also the tool that lets the compiler prove you handled every case — which is why it underpins both recursion and proofs.
Learning Goals
- Match on numbers, options, lists, tuples, and your own types.
- Use the
n + 1pattern to name a predecessor. - Match on several values at once and nest patterns inside each other.
- Read and fix a “Missing cases” error.
- Define your own multi-constructor types with
inductive.
Matching on Nat
1def describe (n : Nat) : String :=2 match n with3 | 0 => "zero"4 | 1 => "one"5 | _ => "many"67#eval describe 0 -- "zero"8#eval describe 1 -- "one"9#eval describe 5 -- "many"Patterns are tried top to bottom, first match wins. The underscore is a wildcard that matches anything and binds nothing. Because it matches everything, a wildcard placed first would shadow every branch below it — put it last.
The n + 1 pattern
Every natural number is either 0 or one more than another natural number. The pattern k + 1 matches the second case and binds kto the predecessor — giving you a smaller number to work with.
1def pred (n : Nat) : Nat :=2 match n with3 | 0 => 04 | k + 1 => k56#eval pred 0 -- 07#eval pred 7 -- 6You can step by more than one. Here the recursive call shrinks the input by two each time, which is enough for Lean to see the function terminates.
1def parity : Nat → String2 | 0 => "even"3 | 1 => "odd"4 | n + 2 => parity n56#eval parity 10 -- "even"7#eval parity 7 -- "odd"n + 1 pattern is where that “smaller” comes from.Matching on Option and List
1def showOption (value : Option String) : String :=2 match value with3 | some name => s!"Hello {name}"4 | none => "No name"56#eval showOption (some "Lean") -- "Hello Lean"7#eval showOption none -- "No name"Lists follow the same two-case shape you met in Level 2: empty, or one element on top of a smaller list.
1def headOption (xs : List Nat) : Option Nat :=2 match xs with3 | [] => none4 | x :: _ => some x56#eval headOption [1, 2, 3] -- some 17#eval headOption [] -- none89-- Patterns can look further than one element ahead10def secondOption : List Nat → Option Nat11 | _ :: y :: _ => some y12 | _ => none1314#eval secondOption [1, 2, 3] -- some 215#eval secondOption [1] -- noneMatching on Several Values at Once
Separate the scrutinees with commas, and separate each branch's patterns the same way.
1def bothZero (a b : Nat) : Bool :=2 match a, b with3 | 0, 0 => true4 | _, _ => false56#eval bothZero 0 0 -- true7#eval bothZero 0 1 -- false89-- Reimplementing boolean and, for illustration10def andB : Bool → Bool → Bool11 | true, true => true12 | _, _ => falseNested Patterns
Patterns compose. You can look inside a structure that is itself inside another one, in a single branch.
1def firstIsSome : List (Option Nat) → String2 | some x :: _ => s!"starts with {x}"3 | none :: _ => "starts with none"4 | [] => "empty"56#eval firstIsSome [some 3, none] -- "starts with 3"7#eval firstIsSome [none] -- "starts with none"8#eval firstIsSome [] -- "empty"Destructuring Tuples
A pattern works anywhere you bind a value — not just in match, but in let and in lambda arguments too.
1def swap (p : Nat × String) : String × Nat :=2 match p with3 | (n, s) => (s, n)45#eval swap (1, "one") -- ("one", 1)67-- Directly in let8def sumPair (p : Nat × Nat) : Nat :=9 let (a, b) := p10 a + b1112#eval sumPair (5, 6) -- 111314-- Directly in a lambda15#eval [(1, 2), (3, 4)].map (fun (a, b) => a + b) -- [3, 7]Your Own Types with inductive
Level 4 gave you structure for data with a fixed set of fields. inductive is the more general tool: it lets a type have several different shapes, each carrying its own data. This is where pattern matching becomes essential rather than merely convenient.
1inductive Shape where2 | circle (r : Nat)3 | rect (w h : Nat)4 | triangle (b h : Nat)5deriving Repr67def area : Shape → Nat8 | .circle r => 3 * r * r -- π ≈ 3, this is Nat after all9 | .rect w h => w * h10 | .triangle b h => b * h / 21112#eval area (.circle 2) -- 1213#eval area (.rect 3 4) -- 1214#eval area (.triangle 6 4) -- 12.circle is shorthand for Shape.circle. Lean fills in the namespace because it already knows from the type signature which type you are matching on. You will see this everywhere in modern Lean code.Option and Listare not built into the language — they are ordinary inductive types defined exactly like this one, which is why matching on them feels identical.
Exhaustiveness: The Compiler Has Your Back
Lean requires every match to cover every possible input. Leave a case out and it will not compile — and the error tells you precisely which case you forgot.
1def bad (xs : List Nat) : Nat :=2 match xs with3 | x :: _ => x45-- error: Missing cases:6-- []This is the same totality requirement from Level 2 that made 3 - 5 equal 0. Every function must produce a result for every input, so “I only handled the non-empty case” is not an option. In exchange, a whole category of runtime crash simply cannot happen in your Lean programs.
Deep Dive: match versus if-then-else
Both exist and both are idiomatic; they answer different questions. match asks what shape is this value? and gives you access to the data inside that shape. if asks is this condition true? and gives you nothing new.
1-- if is the right tool: a condition, no data to extract2def sign (n : Int) : String :=3 if n > 0 then "positive"4 else if n < 0 then "negative"5 else "zero"67-- match is the right tool: you need the x out of the some8def double? (o : Option Nat) : Option Nat :=9 match o with10 | some x => some (x * 2)11 | none => noneA useful rule of thumb: if you find yourself writing if o.isSome then ... and then reaching for the value inside, you wanted a match. The match version cannot get it wrong, because the value is only in scope on the branch where it exists.
Practice
Write lastOption which returns the final element of a list, or nonewhen the list is empty. You will need three cases — think about what distinguishes “exactly one element left” from “more to go”.
Show solution
1def lastOption : List Nat → Option Nat2 | [] => none3 | [x] => some x4 | _ :: rest => lastOption rest56#eval lastOption [1, 2, 3] -- some 37#eval lastOption [] -- none[x] is a pattern for the one-element list — sugar for x :: []. Order matters here: put [x] before the general _ :: rest, or it would never be reached.
Write classify which returns "zero", "one", "two", or "lots".
Show solution
1def classify (n : Nat) : String :=2 match n with3 | 0 => "zero"4 | 1 => "one"5 | 2 => "two"6 | _ => "lots"78#eval classify 2 -- "two"9#eval classify 9 -- "lots"Write zip which pairs up elements from a List Nat and a List String, stopping as soon as either list runs out. This one needs matching on two values at once.
Show solution
1def zip : List Nat → List String → List (Nat × String)2 | [], _ => []3 | _, [] => []4 | x :: xs, y :: ys => (x, y) :: zip xs ys56#eval zip [1, 2, 3] ["a", "b"] -- [(1, "a"), (2, "b")]The two “ran out” cases come first so that the third branch can assume both lists have at least one element. Try reordering them and see what Lean says.
Add a square (side : Nat) constructor to the Shape type above. Before you touch area, save the file and read the error. Then write a name : Shape → String function too.
What to notice
The moment you add the constructor, areastops compiling with a “Missing cases” error pointing at square. That is the feature. In a language without exhaustiveness checking, adding a variant silently leaves every existing switch statement subtly wrong; here the compiler hands you the complete list of places to update.
1def name : Shape → String2 | .circle _ => "circle"3 | .rect _ _ => "rectangle"4 | .triangle _ _ => "triangle"56#eval name (.rect 1 1) -- "rectangle"Common Mistakes
- Putting the wildcard first. It matches everything, so every later branch becomes unreachable.
- Putting the general case before the specific one.
_ :: restbefore[x]means[x]never fires. - Forgetting a case.Read the “Missing cases” list — it is the exact answer, not a hint.
- Using
ifwhen you need the contained value.if o.isSomedoes not give you thex.
n + 1 and x :: xs patterns you met here are the exact shapes that recursion and induction will follow in the next two levels.Check Yourself
- What does
kbind to in the patternk + 1when the input is7? - Why must
[x]come before_ :: rest? - What is
.circleshorthand for, and how does Lean know? - When would you choose
matchoverif?