Local Scope
Use let bindings to create intermediate values and structure your computations clearly.
Let Bindings
The let keyword introduces local variables. These are scoped to the expression that follows:
1def circleArea (radius : Float) : Float :=2 let pi := 3.141593 let radiusSquared := radius * radius4 pi * radiusSquared56#eval circleArea 5.0 -- 78.53975Each let binding creates a name that's available in all subsequent code within the same scope.
Let with Type Annotations
You can add type annotations to let bindings for clarity:
1def example1 : Nat :=2 let x : Nat := 103 let y : Nat := 204 x + y56-- Type annotations are optional when inferable7def example2 : Nat :=8 let x := 10 -- Inferred as Nat9 x * 2Nested Let Bindings
Let bindings can be nested, and each inner binding can use outer ones:
1def compute : Nat :=2 let a := 53 let b := a * 2 -- Uses 'a'4 let c := a + b -- Uses both 'a' and 'b'5 c * 2 -- Result: 3067#eval compute -- 30Variable Shadowing
You can reuse names in inner scopes. The inner definition "shadows" the outer one:
1def shadowExample : Nat :=2 let x := 103 let x := x + 5 -- This 'x' shadows the previous one4 let x := x * 2 -- And this shadows again5 x -- Final value: 3067#eval shadowExample -- 30Let in Expressions
let can be used inline within expressions:
1-- Inline let2#eval let x := 5; x * x -- 2534-- Multiple inline lets5#eval let a := 2; let b := 3; a + b -- 567-- Let in function arguments8#eval List.map (fun n => let doubled := n * 2; doubled + 1) [1, 2, 3]9-- [3, 5, 7]Let vs Where
Both let and where create local bindings, but they have different use cases:
1-- 'let' bindings come BEFORE the main expression2def withLet (x : Nat) : Nat :=3 let doubled := x * 24 let tripled := x * 35 doubled + tripled67-- 'where' bindings come AFTER the main expression8def withWhere (x : Nat) : Nat :=9 doubled + tripled10where11 doubled := x * 212 tripled := x * 31314-- Both produce the same result15#eval withLet 5 -- 2516#eval withWhere 5 -- 25let for step-by-step computation (top to bottom). Use where when you want the main logic first with supporting definitions below.Scope Isolation
Variables defined in one scope don't leak into others:
1def outer : Nat :=2 let x := 103 let inner := 4 let y := 20 -- 'y' is only visible here5 x + y -- Can access 'x' from outer scope6 -- 'y' is not accessible here7 inner + x -- Can use 'inner' and 'x'89#eval outer -- 40Deep Dive: Let is Not Mutation
Coming from imperative languages, let might look like variable assignment. But Lean is purely functional—there is no mutation.
Each let creates a new, immutable binding. Shadowing creates a new binding with the same name, not a modification of the original.
1-- This is NOT mutation:2def notMutation : Nat :=3 let x := 54 let x := 10 -- New binding, old 'x' is gone5 x -- 10Practical Example
Here's a more realistic example showing local scope in action:
1def formatPrice (cents : Nat) : String :=2 let dollars := cents / 1003 let remaining := cents % 1004 let dollarStr := toString dollars5 let centStr := if remaining < 10 6 then "0" ++ toString remaining 7 else toString remaining8 "$" ++ dollarStr ++ "." ++ centStr910#eval formatPrice 1234 -- "$12.34"11#eval formatPrice 50 -- "$0.50"12#eval formatPrice 1005 -- "$10.05"