Custom Type Classes
Define your own type classes to create reusable abstractions over different types.
Defining a Type Class
Use class to define a new type class:
lean
1-- A class for types that can be serialized to JSON2class ToJson (α : Type) where3 toJson : α → String45-- Instances for basic types6instance : ToJson Nat where7 toJson n := toString n89instance : ToJson String where10 toJson s := s!""{s}""1112instance : ToJson Bool where13 toJson b := if b then "true" else "false"1415#eval ToJson.toJson 42 -- "42"16#eval ToJson.toJson "hello" -- ""hello""17#eval ToJson.toJson true -- "true"Classes with Multiple Methods
lean
1class Serializable (α : Type) where2 serialize : α → String3 deserialize : String → Option α45instance : Serializable Nat where6 serialize n := toString n7 deserialize s := s.toNat?89#eval Serializable.serialize 123 -- "123"10#eval Serializable.deserialize "456" -- some 45611#eval (Serializable.deserialize "abc" : Option Nat) -- noneClasses with Laws
Some classes imply laws that instances should satisfy:
lean
1-- A Monoid has an identity and an associative operation2class Monoid (α : Type) where3 empty : α4 append : α → α → α5 -- Laws (not enforced, but expected):6 -- append empty x = x7 -- append x empty = x8 -- append (append x y) z = append x (append y z)910instance : Monoid String where11 empty := ""12 append := String.append1314instance : Monoid (List α) where15 empty := []16 append := List.append1718-- Generic function using Monoid19def concat [Monoid α] (xs : List α) : α :=20 xs.foldl Monoid.append Monoid.empty2122#eval concat ["a", "b", "c"] -- "abc"23#eval concat [[1,2], [3], [4,5]] -- [1, 2, 3, 4, 5]Key Takeaway
Monoid laws aren't checked by the type system here, but in Lean's math library (Mathlib), you can prove that instances satisfy their laws.
Extending Type Classes
Classes can extend other classes:
lean
1-- Semigroup: just append2class Semigroup (α : Type) where3 append : α → α → α45-- Monoid extends Semigroup with identity6class Monoid' (α : Type) extends Semigroup α where7 empty : α89-- Instances must provide all methods10instance : Monoid' Nat where11 empty := 012 append := Nat.add1314-- Functions can use parent class methods15def reduce [Monoid' α] (xs : List α) : α :=16 xs.foldl Semigroup.append Monoid'.emptyMulti-Parameter Type Classes
Classes can involve multiple types:
lean
1-- Convert between types2class Convertible (α β : Type) where3 convert : α → β45instance : Convertible Nat Int where6 convert n := Int.ofNat n78instance : Convertible Int String where9 convert i := toString i1011#eval (Convertible.convert 42 : Int) -- 4212#eval (Convertible.convert (42 : Int) : String) -- "42"Outparams: Functional Dependencies
Use outParam when one type determines another:
lean
1-- The element type determines the collection type uniquely2class Collection (elem : Type) (coll : outParam Type) where3 empty : coll4 insert : elem → coll → coll5 toList : coll → List elem67-- For Nat, the collection is List Nat8instance : Collection Nat (List Nat) where9 empty := []10 insert := List.cons11 toList := id1213-- Lean infers coll from elem14def build [Collection α coll] (xs : List α) : coll :=15 xs.foldl (fun c x => Collection.insert x c) Collection.emptyDeep Dive: Why outParam?
Without outParam, Lean can't figure out the collection type from just the element type. With it, the instance search knows: "If elem is Nat, then coll must be List Nat."
This is similar to "functional dependencies" in Haskell.
Default Implementations
lean
1class Printable (α : Type) where2 -- Required method3 repr : α → String4 5 -- Optional with defaults6 print : α → IO Unit := fun x => IO.println (repr x)7 printList : List α → IO Unit := fun xs => 8 xs.forM (fun x => print x)910-- Only need to implement repr11instance : Printable Nat where12 repr := toString1314-- print and printList work automatically15#eval (Printable.print 42 : IO Unit)Practical Example: A DSL for Expressions
lean
1-- Expression type class2class Expr (α : Type) where3 lit : Int → α4 add : α → α → α5 mul : α → α → α67-- Evaluator instance8instance : Expr Int where9 lit n := n10 add := Int.add11 mul := Int.mul1213-- Pretty printer instance14instance : Expr String where15 lit n := toString n16 add x y := s!"({x} + {y})"17 mul x y := s!"({x} * {y})"1819-- One expression, multiple interpretations20def expr [Expr α] : α :=21 Expr.add (Expr.lit 1) (Expr.mul (Expr.lit 2) (Expr.lit 3))2223#eval (expr : Int) -- 724#eval (expr : String) -- "(1 + (2 * 3))"💡
This pattern—one definition, multiple interpretations—is called "tagless final" style. It's powerful for building DSLs.
When to Create Type Classes
Create a type class when you have:
- Multiple types with the same operations (equality, ordering, serialization)
- A common interface that different types implement differently
- Algorithms that work generically over types with certain capabilities
Avoid type classes when:
- A simple function would suffice
- There's only one type that will ever implement it
- The abstraction doesn't provide meaningful reuse