Basics
True to the title of this article series, we start from zero. Not “Hello, World!” but an actual zero: the natural number that forms the foundation of arithmetic.
Zero
-- From Zero to QED: let's start at the very beginning
def zero : Nat := 0
#eval zero -- Output: 0
-- The natural numbers are defined inductively:
-- Nat.zero is the base case
-- Nat.succ n is the successor of n
def one : Nat := Nat.succ Nat.zero
def two : Nat := Nat.succ (Nat.succ Nat.zero)
#eval one -- Output: 1
#eval two -- Output: 2
-- Of course, we can just write the literals directly
def fortyTwo : Nat := 42
The natural numbers are perhaps the most fundamental type in mathematics and programming. Lean represents them inductively: zero is the base case, and every other natural number is the successor of another. This simple construction gives us the entire infinite sequence 0, 1, 2, 3, and so on.
Now that we have numbers, let us also greet the world:
def greet (name : String) : String :=
s!"Hello, {name}!"
#eval greet "World" -- Output: "Hello, World!"
Natural Numbers
Natural numbers in Lean represent non-negative integers, defined inductively just as Peano intended in 1889. They support standard arithmetic operations, but subtraction truncates at zero since negative results would fall outside the type. This has caused approximately as many bugs as unsigned integers in C, which is to say: more than anyone wants to admit.
-- Natural numbers (Nat) are non-negative integers: 0, 1, 2, 3, ...
def myNat : Nat := 42
def anotherNat : Nat := 100
-- Basic arithmetic
#eval 3 + 5 -- 8
#eval 10 - 3 -- 7 (truncated subtraction: 3 - 10 = 0)
#eval 4 * 7 -- 28
#eval 17 / 5 -- 3 (integer division)
#eval 17 % 5 -- 2 (modulo)
-- Natural number subtraction truncates at zero
#eval (3 : Nat) - 10 -- 0, not -7
-- Comparison
#eval 5 < 10 -- true
#eval 5 ≤ 5 -- true
#eval (10 == 10) -- true
Integers
When you need negative numbers, use Int. Integer arithmetic behaves as you would expect from standard mathematics, unburdened by the horrors of two’s complement overflow that have plagued systems programmers since the PDP-11.
-- Integers (Int) include negative numbers
def myInt : Int := -17
def posInt : Int := 42
-- Integer arithmetic handles negatives properly
#eval (-5 : Int) + 3 -- -2
#eval (3 : Int) - 10 -- -7
#eval (-4 : Int) * (-3) -- 12
#eval (-17 : Int) / 5 -- -3
#eval (-17 : Int) % 5 -- -2
-- Converting between Nat and Int
#eval Int.ofNat 42 -- 42 as Int
#eval (42 : Int).toNat -- 42 as Nat
#eval (-5 : Int).toNat -- 0 (negative becomes 0)
#eval (42 : Int).natAbs -- 42 (absolute value as Nat)
#eval (-42 : Int).natAbs -- 42
Fin
Fin n represents natural numbers strictly less than n. The type carries a proof that its value is in bounds, making it useful for safe array indexing.
-- Fin n is the type of natural numbers less than n
def smallNum : Fin 5 := 3 -- 3 is less than 5
def anotherSmall : Fin 10 := 7 -- 7 is less than 10
-- Fin values carry a proof that they're in bounds
#eval (smallNum : Fin 5).val -- 3 (the underlying Nat)
-- Useful for array indexing with guaranteed bounds
def safeIndex {α : Type} (arr : Array α) (i : Fin arr.size) : α :=
arr[i]
-- Fin arithmetic wraps around
#eval (3 : Fin 5) + 4 -- 2 (wraps: 7 mod 5 = 2)
Tip
Notice that
Fin nbundles a value with a proof about that value. This pattern appears everywhere in Lean. Types can contain proofs. Later you will see this is not a special feature but a consequence of something deeper: the Curry-Howard correspondence, where propositions are types and proofs are values.
Fixed-Precision Integers
For performance-critical code or when interfacing with external systems, Lean provides fixed-precision integers that map directly to machine types.
-- Fixed-precision unsigned integers
def byte : UInt8 := 255
def word : UInt16 := 65535
def dword : UInt32 := 0xDEADBEEF
def qword : UInt64 := 0xCAFEBABE12345678
-- Overflow wraps around
#eval (255 : UInt8) + 1 -- 0
-- Size type for platform-dependent sizing
def platformSize : USize := 42
-- Signed fixed-precision integers
def signedByte : Int8 := -128
def signedWord : Int16 := -32768
Bitvectors
Bitvectors represent fixed-width binary data and support bitwise operations. They are essential for low-level programming, cryptography, and hardware verification.
-- BitVec n is an n-bit vector
def bits8 : BitVec 8 := 0xFF
def bits16 : BitVec 16 := 0xABCD
def bits32 : BitVec 32 := 0xDEADBEEF
-- Bitwise operations
#eval (0b1100 : BitVec 4) &&& 0b1010 -- 0b1000 (AND)
#eval (0b1100 : BitVec 4) ||| 0b1010 -- 0b1110 (OR)
#eval (0b1100 : BitVec 4) ^^^ 0b1010 -- 0b0110 (XOR)
#eval ~~~(0b1100 : BitVec 4) -- 0b0011 (NOT)
-- Shifts
#eval (0b0001 : BitVec 4) <<< 2 -- 0b0100 (left shift)
#eval (0b1000 : BitVec 4) >>> 2 -- 0b0010 (right shift)
Floats
Lean supports IEEE 754 double-precision floating-point numbers for scientific computing and applications that require real number approximations.
-- IEEE 754 double-precision floating-point
def myFloat : Float := 3.14159
def scientific : Float := 6.022e23
def negativeFloat : Float := -273.15
-- Floating-point arithmetic
#eval 3.14 + 2.86 -- 6.0
#eval 10.0 / 3.0 -- 3.333...
#eval Float.sqrt 2.0 -- 1.414...
#eval Float.sin 0.0 -- 0.0
#eval Float.cos 0.0 -- 1.0
-- Special values
#eval (1.0 / 0.0 : Float) -- inf
#eval (0.0 / 0.0 : Float) -- nan
Chars
Characters in Lean are Unicode scalar values, capable of representing any character from any human language, mathematical symbols, and 🐻.
-- Characters are Unicode scalar values
def letterA : Char := 'A'
def digit : Char := '7'
def unicode : Char := 'λ'
def bear : Char := '🐻'
-- Character properties
#eval 'A'.isAlpha -- true
#eval '7'.isDigit -- true
#eval ' '.isWhitespace -- true
#eval 'a'.isLower -- true
#eval 'A'.isUpper -- true
-- Character to/from Nat
#eval 'A'.toNat -- 65
#eval Char.ofNat 65 -- 'A'
-- Case conversion
#eval 'a'.toUpper -- 'A'
#eval 'Z'.toLower -- 'z'
Strings
Strings are sequences of characters with a rich set of operations for text processing. They are UTF-8 encoded, which means you have already won half the battle that consumed the first decade of web development.
-- Strings are sequences of characters
def greeting : String := "Hello, Lean!"
def multiline : String := "Line 1\nLine 2\nLine 3"
-- String operations
#eval "Hello".length -- 5
#eval "Hello".append " World" -- "Hello World"
#eval "Hello" ++ " " ++ "World" -- "Hello World"
#eval "Hello".toList -- ['H', 'e', 'l', 'l', 'o']
-- String interpolation
def shipName := "Mistake Not My Current State Of Alarm"
def shipClass := "GCU"
#eval s!"The {shipClass} {shipName} has entered the system."
-- Substring operations
#eval "Hello World".take 5 -- "Hello"
#eval "Hello World".drop 6 -- "World"
#eval "Hello".isPrefixOf "Hello World" -- true
-- Splitting and joining
#eval "a,b,c".splitOn "," -- ["a", "b", "c"]
#eval ",".intercalate ["a", "b", "c"] -- "a,b,c"
Unit
The Unit type has exactly one value: (). It serves as a placeholder when a function has no meaningful return value, similar to void in C except that void is a lie and Unit is honest about being boring. Colloquially: every function can return Unit because there is only one possible value to return. Category theorists call this the terminal object (for any type \(A\), there exists exactly one function \(A \to \text{Unit}\)), but you do not need category theory to use it.
-- Unit has exactly one value: ()
def nothing : Unit := ()
-- Often used for side-effecting functions
def printAndReturn : IO Unit := do
IO.println "Side effect!"
return ()
-- Unit in function types indicates "no meaningful return value"
def greetIO (name : String) : IO Unit :=
IO.println s!"Hello, {name}!"
Empty
The Empty type has no values at all. Colloquially: you can write a function from Empty to anything because you will never have to actually produce an output, there are no inputs to handle. Category theorists call this the initial object (for any type \(A\), there exists exactly one function \(\text{Empty} \to A\)), but again, the jargon is optional. Empty represents logical impossibility and marks unreachable code branches. If you somehow obtain a value of type Empty, you can derive anything from it, a principle the medievals called ex falso quodlibet: from falsehood, anything follows.
-- Empty has no values at all
-- It represents impossibility or unreachable code
-- If you have a value of type Empty, you can prove anything
def absurd' {α : Type} (e : Empty) : α :=
Empty.elim e
-- Empty is useful for marking impossible cases
inductive Void where -- Custom empty type (equivalent to Empty)
Note
You do not need to fully understand
Unit,Empty, orFinto write Lean programs. Use them when they fit; ignore the theory until you need it. The Proofs and Type Theory articles explain the deeper connections, including the Curry-Howard correspondence that links these types to logic.
Booleans
Booleans represent truth values and form the basis of conditional logic. George Boole would be pleased, though he might find it curious that his algebra of logic became the foundation for arguments about whether 0 or 1 should represent truth.
-- Booleans: true and false
def myBool : Bool := true
def myFalse : Bool := false
-- Boolean operations
#eval true && false -- false (and)
#eval true || false -- true (or)
#eval !true -- false (not)
#eval true ^^ false -- true (xor)
-- Conditionals
def absInt (x : Int) : Int :=
if x < 0 then -x else x
#eval absInt (-5) -- 5
-- Boolean decision
#eval if true then "yes" else "no" -- "yes"
#eval if false then "yes" else "no" -- "no"
Option
The Option type represents values that may or may not exist. It is Lean’s safe alternative to null references, which Tony Hoare famously called his “billion dollar mistake.” With Option, absence is explicit in the type: you cannot forget to check because the compiler will not let you. The hollow log either contains honey or it does not, and you must handle both cases.
-- Option represents a value that may or may not exist
def someValue : Option Nat := some 42
def noValue : Option Nat := none
-- Pattern matching on Option
def getOrDefault (opt : Option Nat) (default : Nat) : Nat :=
match opt with
| some x => x
| none => default
#eval getOrDefault (some 10) 0 -- 10
#eval getOrDefault none 0 -- 0
-- Option combinators
#eval (some 5).map (· * 2) -- some 10
#eval (none : Option Nat).map (· * 2) -- none
#eval (some 5).getD 0 -- 5
#eval (none : Option Nat).getD 0 -- 0
#eval (some 5).isSome -- true
#eval (some 5).isNone -- false
-- Chaining Options
#eval (some 5).bind (fun x => some (x + 1)) -- some 6
Tuples
Tuples combine values of potentially different types into a single value. They are the basic building block for returning multiple values from functions.
-- Tuples combine values of different types
def pair : Nat × String := (42, "answer")
def triple : Nat × String × Bool := (1, "one", true)
-- Accessing tuple elements
#eval pair.1 -- 42
#eval pair.2 -- "answer"
#eval pair.fst -- 42
#eval pair.snd -- "answer"
-- Pattern matching on tuples
def swap {α β : Type} (p : α × β) : β × α :=
let (a, b) := p
(b, a)
#eval swap (1, "hello") -- ("hello", 1)
-- Nested tuples
def nested : (Nat × Nat) × String := ((1, 2), "pair")
#eval nested.1.1 -- 1
#eval nested.1.2 -- 2
Sum Types
Sum types represent a choice between two alternatives. The Except variant is commonly used for error handling.
-- Sum types represent a choice between types
def leftValue : Nat ⊕ String := Sum.inl 42
def rightValue : Nat ⊕ String := Sum.inr "hello"
-- Pattern matching on Sum
def describeSum (s : Nat ⊕ String) : String :=
match s with
| Sum.inl n => s!"A number: {n}"
| Sum.inr str => s!"A string: {str}"
#eval describeSum leftValue -- "A number: 42"
#eval describeSum rightValue -- "A string: hello"
-- Except is like Sum but for error handling
def divideExcept (x y : Nat) : Except String Nat :=
if y == 0 then
Except.error "Division by zero"
else
Except.ok (x / y)
#eval divideExcept 10 2 -- Except.ok 5
#eval divideExcept 10 0 -- Except.error "Division by zero"
Lists
Lists are singly-linked sequences of elements, the workhorse data structure of functional programming since LISP introduced them in 1958. They support pattern matching and have a rich set of higher-order operations. Prepending is \(O(1)\); appending is \(O(n)\). If this bothers you, wait until you meet Arrays.
-- Linked lists
def myList : List Nat := [1, 2, 3, 4, 5]
def emptyList : List Nat := []
-- List construction
def consExample := 0 :: [1, 2, 3] -- [0, 1, 2, 3]
def appendExample := [1, 2] ++ [3, 4] -- [1, 2, 3, 4]
-- Common operations
#eval [1, 2, 3].length -- 3
#eval [1, 2, 3].head? -- some 1
#eval [1, 2, 3].tail? -- some [2, 3]
#eval [1, 2, 3][1]? -- some 2
#eval [1, 2, 3].reverse -- [3, 2, 1]
-- Higher-order functions
#eval [1, 2, 3].map (· * 2) -- [2, 4, 6]
#eval [1, 2, 3, 4].filter (· > 2) -- [3, 4]
#eval [1, 2, 3, 4].foldl (· + ·) 0 -- 10
-- Cartesian product via flatMap
#eval [1, 2].flatMap (fun x => [10, 20].map (fun y => x + y)) -- [11, 21, 12, 22]
Arrays
Arrays provide \(O(1)\) random access and are the preferred choice when you need indexed access without the pointer-chasing of linked lists. Thanks to Lean’s reference counting, operations on unshared arrays mutate in place, giving you the performance of imperative code with the semantics of pure functions. Purity without the performance penalty. The trick is that “unshared” does a lot of work in that sentence.
-- Arrays provide O(1) random access
def myArray : Array Nat := #[1, 2, 3, 4, 5]
def emptyArray : Array Nat := #[]
-- Array operations
#eval #[1, 2, 3].size -- 3
#eval #[1, 2, 3][0]! -- 1 (panics if out of bounds)
#eval #[1, 2, 3][1]? -- some 2
#eval #[1, 2, 3][10]? -- none
-- Modifying arrays (creates new array)
#eval #[1, 2, 3].push 4 -- #[1, 2, 3, 4]
#eval #[1, 2, 3].pop -- #[1, 2]
#eval #[1, 2, 3].set! 1 99 -- #[1, 99, 3]
-- Conversion
#eval #[1, 2, 3].toList -- [1, 2, 3]
#eval [1, 2, 3].toArray -- #[1, 2, 3]
-- Higher-order functions
#eval #[1, 2, 3].map (· * 2) -- #[2, 4, 6]
#eval #[1, 2, 3, 4].filter (· % 2 == 0) -- #[2, 4]
ByteArrays
ByteArrays are efficient arrays of bytes, useful for binary data, file I/O, and network protocols.
-- ByteArray is an efficient array of bytes
def bytes : ByteArray := ByteArray.mk #[0x48, 0x65, 0x6C, 0x6C, 0x6F]
-- Operations
#eval bytes.size -- 5
#eval bytes.get! 0 -- 72 (0x48 = 'H')
#eval bytes.toList -- [72, 101, 108, 108, 111]
-- Convert to/from String (UTF-8)
#eval "Hello".toUTF8 -- ByteArray from string
Maps and Sets
Hash maps and hash sets provide efficient key-value storage and membership testing.
-- HashMap for key-value storage
open Std in
def myMap : HashMap String Nat :=
HashMap.emptyWithCapacity
|>.insert "one" 1
|>.insert "two" 2
|>.insert "three" 3
#eval myMap.get? "two" -- some 2
#eval myMap.get? "four" -- none
#eval myMap.contains "one" -- true
#eval myMap.size -- 3
-- HashSet for unique elements
open Std in
def mySet : HashSet Nat :=
HashSet.emptyWithCapacity
|>.insert 1
|>.insert 2
|>.insert 3
|>.insert 2 -- duplicate ignored
#eval mySet.contains 2 -- true
#eval mySet.contains 5 -- false
#eval mySet.size -- 3
Subtypes
Subtypes refine an existing type with a predicate. The value carries both the data and a proof that the predicate holds. This is where dependent types begin to flex: instead of checking at runtime whether a number is positive, you encode positivity in the type itself. The predicate becomes part of the contract, enforced at compile time.
-- Subtypes refine a type with a predicate
def Positive := { n : Nat // n > 0 }
def five : Positive := ⟨5, by decide⟩
-- Accessing subtype values
#eval five.val -- 5 (the underlying Nat)
-- five.property is a proof that 5 > 0
-- Common subtypes
def NonEmptyList (α : Type) := { xs : List α // xs ≠ [] }
def exampleNEL : NonEmptyList Nat :=
⟨[1, 2, 3], by decide⟩
-- Safe head function for non-empty lists
def safeHead {α : Type} [Inhabited α] (nel : NonEmptyList α) : α :=
match nel.val with
| x :: _ => x
| [] => default -- unreachable due to property, but needed for totality
#eval safeHead exampleNEL -- 1
Functions
Functions are first-class values in Lean. You can define them in multiple ways and partially apply them to create new functions.
def add (x : Nat) (y : Nat) : Nat :=
x + y
def double : Nat → Nat :=
fun x => 2 * x
def addFive := add 5 -- Partially applied function
#eval add 3 4 -- Output: 7
#eval double 21 -- Output: 42
#eval addFive 10 -- Output: 15
Pattern Matching
Pattern matching is a powerful feature for destructuring data and defining functions by cases.
def factorial : Nat → Nat
| 0 => 1
| n + 1 => (n + 1) * factorial n
def describe : Nat → String
| 0 => "zero"
| 1 => "one"
| 2 => "two"
| n => s!"many ({n})"
#eval factorial 5 -- Output: 120
#eval describe 0 -- Output: "zero"
#eval describe 100 -- Output: "many (100)"
Structures
Structures are Lean’s way of grouping related data with named fields.
structure Point where
x : Float
y : Float
deriving Repr
def origin : Point := ⟨0.0, 0.0⟩
def myPoint : Point := { x := 3.0, y := 4.0 }
def distance (p : Point) : Float :=
Float.sqrt (p.x * p.x + p.y * p.y)
#eval distance myPoint -- Output: 5.0
Inductive Types
Inductive types allow you to define custom data types by specifying their constructors.
inductive SpellSchool where
| abjuration -- Protective magic
| conjuration -- Summoning things from elsewhere
| divination -- Knowing things you shouldn't
| enchantment -- Making friends (involuntarily)
| evocation -- Fireballs, obviously
| illusion -- Lying, but with magic
| necromancy -- Asking corpses for favors
| transmutation -- Turning lead into gold (or frogs)
deriving Repr, DecidableEq
def schoolDanger : SpellSchool → Nat
| .abjuration => 1
| .divination => 2
| .illusion => 3
| .transmutation => 4
| .enchantment => 5
| .conjuration => 6
| .evocation => 8 -- Fireballs in enclosed spaces
| .necromancy => 9 -- Ethical concerns
inductive MyList (α : Type) where
| nil : MyList α
| cons : α → MyList α → MyList α
def MyList.length {α : Type} : MyList α → Nat
| MyList.nil => 0
| MyList.cons _ tail => 1 + tail.length
Type Classes
Type classes provide a way to define generic interfaces that can be implemented for different types.
def showTwice {α : Type} [ToString α] (x : α) : String :=
s!"{x} {x}"
#eval showTwice 42 -- Output: "42 42"
#eval showTwice "hello" -- Output: "hello hello"
#eval showTwice true -- Output: "true true"
Example Programs
Now let’s put everything together to build some non-trivial trivial programs! The following examples demonstrate Lean as a general-purpose programming language, showing how its type system and functional style work together in practice.
FizzBuzz
FizzBuzz is the canonical “can you actually program” interview question, famous for its ability to filter candidates who cannot write a loop. Here it demonstrates pattern matching on multiple conditions: “Fizz” for multiples of 3, “Buzz” for multiples of 5, “FizzBuzz” for both, and the number itself otherwise. The Lean version is more elegant than the nested conditionals you have seen in lesser languages.
-- FizzBuzz
-- Run with: lake exe fizzbuzz [limit]
def fizzbuzz (n : Nat) : String :=
match n % 3 == 0, n % 5 == 0 with
| true, true => "FizzBuzz"
| true, false => "Fizz"
| false, true => "Buzz"
| false, false => toString n
def runFizzbuzz (limit : Nat) : List String :=
(List.range limit).map fun i => fizzbuzz (i + 1)
def main (args : List String) : IO Unit := do
let limit := match args.head? >>= String.toNat? with
| some n => if n > 0 then n else 15
| none => 15
IO.println s!"FizzBuzz 1 to {limit}"
IO.println "================="
for s in runFizzbuzz limit do
IO.println s
Build and run with:
lake exe fizzbuzz 20
Word Frequency
This example uses a hash map to count word occurrences in a string. The foldl function threads an accumulator through the word list, updating counts as it goes.
-- Word Frequency Counter
-- Run with: lake exe wordfreq "your text here"
import Std.Data.HashMap
open Std in
def wordFrequency (text : String) : HashMap String Nat :=
let words := text.toLower.splitOn " "
words.foldl (init := HashMap.emptyWithCapacity) fun acc word =>
let count := acc.getD word 0
acc.insert word (count + 1)
def main (args : List String) : IO Unit := do
let text := match args.head? with
| some t => t
| none => "the spice must flow the spice extends life the spice expands consciousness"
IO.println "Word Frequency Counter"
IO.println "======================"
IO.println s!"Text: \"{text}\""
IO.println ""
IO.println "Frequencies:"
let freq := wordFrequency text
for (word, count) in freq.toList do
IO.println s!" {word}: {count}"
Build and run with:
lake exe wordfreq "the spice must flow the spice extends life"
Collatz Conjecture
The Collatz conjecture states that repeatedly applying a simple rule (halve if even, triple and add one if odd) will eventually reach 1 for any positive starting integer. Proposed in 1937, it remains unproven. Mathematicians have verified it for numbers up to \(2^{68}\), yet no one can prove it always works. Erdos famously said of Collatz that “Mathematics is not yet ready for such problems.”
But we can still explore it computationally in Lean!
/-- The Collatz conjecture: every positive integer eventually reaches 1.
Unproven since 1937, but we can at least watch the journey. -/
def collatzStep (n : Nat) : Nat :=
if n % 2 == 0 then n / 2 else 3 * n + 1
def collatzSequence (n : Nat) (fuel : Nat := 1000) : List Nat :=
match fuel with
| 0 => [n] -- give up, though Collatz would be disappointed
| fuel' + 1 =>
if n <= 1 then [n]
else n :: collatzSequence (collatzStep n) fuel'
def collatzLength (n : Nat) : Nat :=
(collatzSequence n).length
-- The famous 27: takes 111 steps and peaks at 9232
#eval collatzSequence 27
#eval collatzLength 27
-- Find the longest sequence for starting values 1 to n
def longestCollatz (n : Nat) : Nat × Nat :=
(List.range n).map (· + 1)
|>.map (fun k => (k, collatzLength k))
|>.foldl (fun acc pair => if pair.2 > acc.2 then pair else acc) (1, 1)
#eval longestCollatz 100 -- (97, 119)
The full Collatz explorer is available as a standalone executable. Build and run it with:
lake exe collatz 27