Data Structures
Programs are nothing without data to manipulate. Here we cover the types that hold your data: from simple primitives like characters and booleans to collections like lists and arrays to user-defined structures and inductive types. By the end, you will have the vocabulary to represent any data your program needs.
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 bears.
-- 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
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: Magic The Gathering
We now have enough Lean to model something from the real world. Naturally, we choose Magic: The Gathering. The game has been proven Turing complete and as hard as the halting problem, so it makes a worthy adversary.
Mana System
Five colors of mana (white, blue, black, red, green) plus colorless. An inductive type captures the colors; a structure captures costs with default values of zero:
inductive ManaColor where
| white | blue | black | red | green | colorless
deriving Repr, DecidableEq
structure ManaCost where
white : Nat := 0
blue : Nat := 0
black : Nat := 0
red : Nat := 0
green : Nat := 0
colorless : Nat := 0
deriving Repr
def ManaCost.total (c : ManaCost) : Nat :=
c.white + c.blue + c.black + c.red + c.green + c.colorless
Players accumulate mana in a pool. The key question: can you afford a spell? The pay function returns Option ManaPool, giving back the remaining mana on success or none if you cannot afford the cost:
structure ManaPool where
white : Nat := 0
blue : Nat := 0
black : Nat := 0
red : Nat := 0
green : Nat := 0
colorless : Nat := 0
deriving Repr
def ManaPool.total (p : ManaPool) : Nat :=
p.white + p.blue + p.black + p.red + p.green + p.colorless
def ManaPool.canAfford (pool : ManaPool) (cost : ManaCost) : Bool :=
pool.white >= cost.white &&
pool.blue >= cost.blue &&
pool.black >= cost.black &&
pool.red >= cost.red &&
pool.green >= cost.green &&
pool.total >= cost.total
def ManaPool.pay (pool : ManaPool) (cost : ManaCost) : Option ManaPool :=
if pool.canAfford cost then
some { white := pool.white - cost.white
blue := pool.blue - cost.blue
black := pool.black - cost.black
red := pool.red - cost.red
green := pool.green - cost.green
colorless := pool.total - cost.total -
(pool.white - cost.white) - (pool.blue - cost.blue) -
(pool.black - cost.black) - (pool.red - cost.red) -
(pool.green - cost.green) }
else
none
Card Types
Cards come in different types. Creatures have power and toughness; instants, sorceries, and artifacts do not. An inductive type with arguments captures this: the creature constructor carries two Nat values, while others carry nothing:
inductive CardType where
| creature (power : Nat) (toughness : Nat)
| instant
| sorcery
| enchantment
| artifact
deriving Repr
structure Card where
name : String
cost : ManaCost
cardType : CardType
deriving Repr
Some iconic cards to work with. Note how .creature 2 2 constructs a creature type inline, using the dot notation shorthand:
def goblinGuide : Card :=
{ name := "Goblin Guide"
cost := { red := 1 }
cardType := .creature 2 2 }
def searingSpear : Card :=
{ name := "Searing Spear"
cost := { red := 1, colorless := 1 }
cardType := .instant }
def dayOfJudgment : Card :=
{ name := "Day of Judgment"
cost := { white := 2, colorless := 2 }
cardType := .sorcery }
def swordOfFire : Card :=
{ name := "Sword of Fire and Ice"
cost := { colorless := 3 }
cardType := .artifact }
def graveTitan : Card :=
{ name := "Grave Titan"
cost := { black := 2, colorless := 4 }
cardType := .creature 6 6 }
Pattern matching extracts information from cards. Querying a non-creature for its power returns none:
def Card.isCreature (c : Card) : Bool :=
match c.cardType with
| .creature _ _ => true
| _ => false
def Card.power (c : Card) : Option Nat :=
match c.cardType with
| .creature p _ => some p
| _ => none
def Card.toughness (c : Card) : Option Nat :=
match c.cardType with
| .creature _ t => some t
| _ => none
Combat
Creatures on the battlefield can attack and block. A Creature structure tracks accumulated damage. When damage meets or exceeds toughness, the creature dies:
structure Creature where
name : String
power : Nat
toughness : Nat
damage : Nat := 0
deriving Repr
def Creature.fromCard (c : Card) : Option Creature :=
match c.cardType with
| .creature p t => some { name := c.name, power := p, toughness := t }
| _ => none
def Creature.isAlive (c : Creature) : Bool :=
c.damage < c.toughness
def Creature.takeDamage (c : Creature) (dmg : Nat) : Creature :=
{ c with damage := c.damage + dmg }
def Creature.canBlock (blocker attacker : Creature) : Bool :=
blocker.isAlive && attacker.isAlive
def combat (attacker blocker : Creature) : Creature × Creature :=
let attackerAfter := attacker.takeDamage blocker.power
let blockerAfter := blocker.takeDamage attacker.power
(attackerAfter, blockerAfter)
Combat is simultaneous: both creatures deal damage at once. Two 2/2 creatures blocking each other results in mutual destruction.
Hand Management
A hand is a list of cards. List operations let us query it: which cards can we cast with our current mana? How many creatures do we have? What is the total mana cost?
abbrev Hand := List Card
def Hand.playable (hand : Hand) (pool : ManaPool) : List Card :=
hand.filter (fun c => pool.canAfford c.cost)
def Hand.creatures (hand : Hand) : List Card :=
hand.filter Card.isCreature
def Hand.totalCost (hand : Hand) : Nat :=
hand.foldl (fun acc c => acc + c.cost.total) 0
The filter function takes a predicate and keeps matching elements. The foldl function reduces a list to a single value. These are the workhorses of functional programming, and they compose naturally: hand.filter Card.isCreature gives all creatures, hand.playable pool gives everything castable.
Tip
Run from the repository:
lake exe mtg. The full source is on GitHub.
From Data to Control
You now have the building blocks for representing any data your program needs. Next we cover how to work with that data: control flow, recursion, and the patterns that give programs their structure.