Type Classes
Type classes let Lean infer behaviour automatically. They are the machinery behind +, ==, #eval's printing, and deriving— and once you can write your own, a lot of Lean stops looking like magic.
Learning Goals
- Declare a class and provide instances for several types.
- Write functions that work for any type satisfying a constraint.
- Implement the standard classes:
ToString,BEq,Inhabited,Add. - Give a class a default method and override it.
- Explain what
derivingwas doing all along.
The Problem Type Classes Solve
You want + to work on Nat, Int, Float, matrices, and your own types. Defining addNat, addInt, addFloat separately means every generic function must be written once per type. A type class instead declares an interface, and lets each type say how it satisfies that interface. Lean then finds the right implementation for you.
Defining a Class
1class Summable (α : Type) where2 zero : α3 add : α → α → α45instance : Summable Nat where6 zero := 07 add := Nat.add89instance : Summable String where10 zero := ""11 add := String.appendThe class says “a type is Summableif it has a zero and a way to combine two values”. Each instance is the evidence that a particular type qualifies.
Using Instances
Square brackets in a signature mean “require an instance, and find it for me”. You never pass it explicitly.
1def sumPair [Summable α] (x y : α) : α :=2 Summable.add x y34#eval sumPair 3 4 -- 75#eval sumPair "Lean" " 4" -- "Lean 4"One definition, two types, no overloading table to maintain. Add an instance for a new type tomorrow and sumPair starts working on it without being touched.
1def sumAll [Summable α] : List α → α2 | [] => Summable.zero3 | x :: xs => Summable.add x (sumAll xs)45#eval sumAll [1, 2, 3] -- 66#eval sumAll ["a", "b", "c"] -- "abc"Summable.zerois remarkable if you stop on it. There are no arguments to inspect, so Lean cannot infer the type from a value — it works backwards from the expected return type of the function. Instance resolution is driven by types, not by values, which is exactly why it can resolve a bare constant.The Standard Classes
Most of what felt built-in earlier in the course is an instance of a class you can implement yourself. Here is the same structure gaining printing, equality, a default, and arithmetic.
ToString and Repr
1structure Point where2 x : Nat3 y : Nat45instance : ToString Point where6 toString p := s!"({p.x}, {p.y})"78#eval toString (Point.mk 1 2) -- "(1, 2)"9#eval s!"the point is {Point.mk 3 4}" -- "the point is (3, 4)"1011instance : Repr Point where12 reprPrec p _ := s!"Point({p.x}, {p.y})"1314#eval Point.mk 5 6 -- Point(5, 6)ToString produces text for users — whatever reads nicely. Repr produces text for developers, ideally something you could paste back into source. That is why #eval uses Repr and string interpolation uses ToString.BEq and Inhabited
1instance : BEq Point where2 beq a b := a.x == b.x && a.y == b.y34#eval Point.mk 1 2 == Point.mk 1 2 -- true5#eval Point.mk 1 2 == Point.mk 9 9 -- false67instance : Inhabited Point where8 default := Point.mk 0 0910#eval (default : Point) -- Point(0, 0)Overloading operators
+ is notation for the Add class. Provide the instance and the operator starts working on your type.
1instance : Add Point where2 add a b := Point.mk (a.x + b.x) (a.y + b.y)34#eval Point.mk 1 2 + Point.mk 10 20 -- Point(11, 22)The heterogeneous variants let the two operands have different types — scalar multiplication, for instance, where the left side is a Nat and the right is a Point.
1instance : HMul Nat Point Point where2 hMul k p := Point.mk (k * p.x) (k * p.y)34#eval 3 * Point.mk 1 2 -- Point(3, 6)Default Methods
A class can supply a default implementation, defined in terms of the other fields. Instances get it for free but may override it.
1class Greeter (α : Type) where2 name : α → String3 greet : α → String := fun a => s!"Hello, {name a}!"45structure Dog where6 nick : String78-- Only supplies name; greet comes from the default9instance : Greeter Dog where10 name d := d.nick1112#eval Greeter.greet (Dog.mk "Rex") -- "Hello, Rex!"1314structure Robot where15 id : Nat1617-- Overrides the default18instance : Greeter Robot where19 name r := s!"unit-{r.id}"20 greet r := s!"BEEP. UNIT-{r.id} ONLINE."2122#eval Greeter.greet (Robot.mk 7) -- "BEEP. UNIT-7 ONLINE."greet r := s!"... {name r} ..."gives “unknown identifier name”. Reach for the underlying data (r.id) or qualify it as Greeter.name. The bare name only works inside the class declaration, where the default lives.Extending Classes
extends builds a class that requires everything the parent requires plus more. An instance of the child automatically provides the parent.
1class Shape (α : Type) where2 area : α → Nat34class NamedShape (α : Type) extends Shape α where5 shapeName : α → String67structure Sq where8 side : Nat910instance : NamedShape Sq where11 area s := s.side * s.side12 shapeName _ := "square"1314#eval Shape.area (Sq.mk 4) -- 1615#eval NamedShape.shapeName (Sq.mk 4) -- "square"This is how Lean's algebraic hierarchy is organised: a group is a monoid with inverses, a ring is an additive group plus multiplication, and so on. Each layer states only what it adds.
Constraints Compose
A function can demand several instances at once. It then works for every type that satisfies all of them.
1def describeAll [ToString α] (xs : List α) : String :=2 xs.map toString |>.foldl (· ++ ·) ""34#eval describeAll [Point.mk 1 1, Point.mk 2 2] -- "(1, 1)(2, 2)"56def maxOf [LE α] [DecidableLE α] (a b : α) : α :=7 if a <= b then b else a89#eval maxOf 3 9 -- 910#eval maxOf "abc" "abd" -- "abd"maxOf needs two things: an ordering (LE) and the knowledge that the ordering can actually be computed (DecidableLE) — a direct callback to the Bool versus Prop split from Level 5. a ≤ b is a proposition; running if on it requires a decision procedure.
What deriving Was Doing
Now the Level 4 shortcut makes sense. deriving generates instances of exactly these classes, following the structure of your type.
1structure Vec2 where2 a : Nat3 b : Nat4deriving Repr, BEq, Inhabited56-- Equivalent to writing three instances by hand7#eval Vec2.mk 1 2 -- { a := 1, b := 2 }8#eval Vec2.mk 1 2 == Vec2.mk 1 2 -- true9#eval (default : Vec2) -- { a := 0, b := 0 }Write instances by hand when you want control over the behaviour — a custom display format, or an equality that ignores a cache field. Otherwise derive.
Deep Dive: How instance resolution actually works
When Lean sees sumPair 3 4 it infers α := Nat, then searches for a value of type Summable Nat. The search runs over all instances in scope, and it is recursive: an instance can itself require instances. That is how Repr (List (Option Nat)) gets solved — by chaining Repr Nat, then Repr (Option _), then Repr (List _).
Two consequences worth knowing. First, instances are found by typealone, so you cannot have two competing instances for the same type without ambiguity — if you define a second Summable Nat that multiplies, the later one wins by priority and the results change silently. Second, resolution happens at elaboration time, so there is no runtime dispatch cost: by the time your code runs, the implementation has been baked in.
When an instance is not found, the error is failed to synthesizefollowed by the type it wanted. Read that type carefully — it tells you precisely which instance to write or derive.
Practice
Define a Money structure holding an amount in cents. Give it an Add instance so + works, and a ToString instance that renders it as dollars.
Show solution
1structure Money where2 cents : Nat3deriving Repr45instance : Add Money where6 add a b := Money.mk (a.cents + b.cents)78instance : ToString Money where9 toString m := s!"${m.cents / 100}.{m.cents % 100}"1011#eval Money.mk 250 + Money.mk 175 -- { cents := 425 }12#eval toString (Money.mk 425) -- "$4.25"Storing cents rather than dollars is the fix for the truncation trap from Level 4. Note the display is still naive — Money.mk 405 renders as "$4.5". Padding that correctly is a good extra exercise.
Extend the Shape class above with a perimeter field, and add a default describe method that reports both numbers. Then write instances for a square and a rectangle, overriding describe for one of them.
Harder, and worth the effort. Classes can range over type constructors, not just types. Define a Container class over f : Type → Type with an empty value, an insert, and a conversion to List. Then instantiate it for List itself.
Show solution
1class Container (f : Type → Type) where2 empty : f α3 insert : α → f α → f α4 toList : f α → List α56instance : Container List where7 empty := []8 insert x xs := x :: xs9 toList xs := xs1011#eval Container.toList12 (Container.insert 1 (Container.insert 2 (Container.empty : List Nat)))13-- [1, 2]The parameter is List, not List Nat— the class abstracts over the container shape while leaving the element type free. This is the pattern behind Functor, Monad, and much of Lean's effect system.
Common Mistakes
- Using
(inst : C α)instead of[C α]. Round brackets make it an ordinary argument you must pass by hand. - Referring to a sibling field by bare name inside an instance. Qualify it or use the underlying data.
- Defining a second instance for a type that already has one. No error, but the results change depending on which wins.
- Panicking at “failed to synthesize”. It names the exact instance you need. Usually the fix is one
derivingclause.
deriving— so learning to write your own turns a large amount of apparently built-in magic into ordinary code you can read and extend.Check Yourself
- What is the difference between
[Summable α]and(s : Summable α)? - How can Lean resolve
Summable.zerowhen it has no arguments to inspect? - When would you write
Reprby hand instead of deriving it? - Why does
maxOfneedDecidableLEand not justLE?
The advanced course continues with list proofs, where type class inference and rewriting often appear together.
View Advanced Track