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:
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
A type class can require multiple methods. Instances must implement all of them, ensuring that any type claiming to be Serializable provides both serialization and deserialization.
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:
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]Designing Minimal Interfaces
Keep type classes small. Prefer a few core methods plus derived helpers rather than many overlapping operations. This reduces instance burden and improves reuse.
Extending Type Classes
Classes can extend other classes:
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
Type classes can involve multiple types. This is useful for encoding relationships between types, such as conversions or compatibility constraints.
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:
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
Type classes can provide default implementations for methods. This reduces the burden on instance authors—they only need to implement the core methods, and the rest come for free.
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
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))"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
Define a type class for pretty-printing and implement it for a pair.
1class Pretty (α : Type) where2 pretty : α → String34instance [Pretty α] [Pretty β] : Pretty (α × β) where5 pretty p := s!"({Pretty.pretty p.1}, {Pretty.pretty p.2})"67instance : Pretty Nat where8 pretty := toString910#eval Pretty.pretty (1, 2)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