Structures
Structures let you model real-world data with named fields and strong type guarantees. They are Lean's record type — and the first tool that makes your programs read like the domain they describe.
Learning Goals
- Declare a structure and build values three different ways.
- Use
derivingto get printing and equality for free. - Update records without mutating them.
- Attach methods to your own types so dot notation works.
- Give fields defaults and reuse structures with
extends.
Defining Structures
A structure declaration creates a new type, a constructor, and one accessor function per field. You can see all three with #check.
1structure User where2 name : String3 age : Nat45#check User -- User : Type6#check User.mk -- User.mk (name : String) (age : Nat) : User7#check User.name -- User.name (self : User) : String89def alice : User := { name := "Alice", age := 30 }1011#eval alice.name -- "Alice"12#eval alice.age -- 30alice.name is shorthand for User.name alice. Lean sees that alice has type User and looks the function up in the User namespace. This is the same mechanism that made "Lean".length work back in Level 2.
Making Your Structure Printable
Try #eval alice with the definition above and Lean will refuse: it has no idea how to display a User. Add deriving Repr and Lean writes the printing code for you.
1structure Point where2 x : Nat3 y : Nat4deriving Repr56def origin : Point := { x := 0, y := 0 }78#eval origin -- { x := 0, y := 0 }#eval complains about a missing Repr instance, you almost always just want to add deriving Repr to the type. It is the most common one-line fix in early Lean.Three Ways to Build a Value
1-- 1. Named fields — clearest, order does not matter2def a : Point := { x := 3, y := 4 }34-- 2. Anonymous constructor — concise, order does matter5def b : Point := ⟨3, 4⟩67-- 3. Explicit constructor call8def c : Point := Point.mk 3 4The angle brackets ⟨ ⟩ are typed as \< and \>in VS Code. They work whenever Lean already knows which type you are constructing. Prefer named fields for anything with more than two or three fields — positional arguments get misread.
Updating Records
Values in Lean are immutable. “Updating” a record means building a new one that copies the old fields, and { x with ... } is the syntax for it.
1def birthday (u : User) : User :=2 { u with age := u.age + 1 }34#eval (birthday alice).age -- 315#eval alice.age -- 30 ← the original is untouched67def shiftX (p : Point) (d : Nat) : Point :=8 { p with x := p.x + d }910#eval shiftX ⟨3, 4⟩ 10 -- { x := 13, y := 4 }birthdaycannot modify its argument, you can reason about it purely from its type and body — no hidden action at a distance. That property is what later lets you prove things about your code.Default Field Values
Fields can carry defaults, which makes configuration-style structures pleasant to use: supply only what differs from the norm.
1structure Config where2 host : String := "localhost"3 port : Nat := 80804 debug : Bool := false5deriving Repr67#eval ({} : Config)8-- { host := "localhost", port := 8080, debug := false }910#eval ({ port := 3000 } : Config)11-- { host := "localhost", port := 3000, debug := false }Methods and Dot Notation
To get myRect.area style calls on your own types, define the function inside a namespace matching the type name, taking the value as its first argument.
1namespace Point23def norm1 (p : Point) : Nat := p.x + p.y45def translate (p : Point) (dx dy : Nat) : Point :=6 ⟨p.x + dx, p.y + dy⟩78end Point910#eval (⟨3, 4⟩ : Point).norm1 -- 711#eval (⟨3, 4⟩ : Point).translate 1 1 -- { x := 4, y := 5 }Note how translate takes extra arguments after the structure: dot notation fills in the first argument of matching type and passes the rest along normally.
What Else You Can Derive
Repr is the one you will reach for first, but it is not alone. BEq gives you ==, and Inhabited gives you a default value.
1structure Pair where2 a : Nat3 b : Nat4deriving Repr, BEq, Inhabited56#eval (⟨1, 2⟩ : Pair) == (⟨1, 2⟩ : Pair) -- true7#eval (⟨1, 2⟩ : Pair) == (⟨1, 3⟩ : Pair) -- false8#eval (default : Pair) -- { a := 0, b := 0 }Deriving works by generating instances of type classes— the mechanism behind all of Lean's overloaded notation. Level 9 shows you how to write these instances by hand.
Nesting and Extending
Structures compose. A field can be another structure, and accessors chain naturally.
1structure Address where2 city : String3 zip : String4deriving Repr56structure Person where7 name : String8 address : Address9deriving Repr1011def bob : Person :=12 { name := "Bob", address := { city := "Paris", zip := "75001" } }1314#eval bob.address.city -- "Paris"extends builds a structure that contains all the fields of another one, plus its own. Lean also generates a conversion back to the parent type.
1structure Employee extends Person where2 salary : Nat3deriving Repr45def carol : Employee :=6 { name := "Carol"7 , address := { city := "Lyon", zip := "69001" }8 , salary := 50000 }910#eval carol.name -- "Carol" inherited field11#eval carol.salary -- 5000012#eval carol.toPerson -- { name := "Carol", address := ... }Deep Dive: Structures are single-constructor inductive types
A structure is not a separate language feature bolted on for convenience. It is shorthand for an inductive type with exactly one constructor, plus automatically generated accessor functions.
That single-constructor restriction is exactly what makes accessors safe. Because a User can only have been built one way, .name always has something to return — no case analysis needed. Types with several constructors, like Option with its some and none, cannot have accessors for that reason: you must match to find out which shape you have. That is Level 6.
Practice
Create a Product structure with a name and a price, then write discount which knocks 10% off the price.
Show solution
1structure Product where2 name : String3 price : Nat4deriving Repr56def discount (p : Product) : Product :=7 { p with price := p.price - p.price / 10 }89#eval discount { name := "Book", price := 100 }10-- { name := "Book", price := 90 }discount { name := "Pen", price := 5 } returns a price of 5, not 4.5 or 4. Since 5 / 10 truncates to 0 in Nat, the discount vanishes. This is the Level 2 truncation rule biting in a realistic setting — if you were modelling real money you would store cents as a Nat, not whole units.Define a Rect structure with a width and a height. Then, in its namespace, add area, perimeter, and isSquare so that all three work with dot notation.
Show solution
1structure Rect where2 width : Nat3 height : Nat4deriving Repr56namespace Rect78def area (r : Rect) : Nat := r.width * r.height9def perimeter (r : Rect) : Nat := 2 * (r.width + r.height)10def isSquare (r : Rect) : Bool := r.width == r.height1112end Rect1314#eval (⟨3, 4⟩ : Rect).area -- 1215#eval (⟨3, 4⟩ : Rect).perimeter -- 1416#eval (⟨3, 3⟩ : Rect).isSquare -- trueOpen-ended, and the most valuable of the three. Pick something from a project you actually work on — an HTTP request, a chess piece, a bank transaction — and model it as a structure. Give at least one field a default, derive Repr, and write one function that returns an updated copy. Ask yourself while you do it: which invalid states does my type still allow? Level 6 gives you the tool to rule more of them out.
Common Mistakes
- Forgetting
deriving Repr.The resulting “failed to synthesize” error is intimidating but means only that. - Expecting mutation.
{ u with age := 31 }returns a new record; it does not changeu. Use the return value. - Mixing up field order in
⟨ ⟩. Two fields of the same type will silently swap. Use named fields when it matters. - Defining methods outside the namespace. Dot notation only finds
Rect.area, not a top-levelarea.
derivingfor printing and equality, namespaces for dot notation, and record update for immutable change — that quartet covers most day-to-day data modelling in Lean.Check Yourself
- What three things does
structure User where ...generate? - Why does
#eval alicefail while#eval alice.nameworks? - What does
{ p with x := 0 }do top? - Where must
arealive forr.areato compile?