The Concept of Type Classes
Type classes enable ad-hoc polymorphism—writing functions that work with any type that provides certain operations.
The Problem: Ad-Hoc Polymorphism
Consider printing values. Different types need different printing logic:
1-- We want one "show" function that works for many types2-- But each type has different logic:34def showNat (n : Nat) : String := toString n5def showBool (b : Bool) : String := if b then "true" else "false"6def showList (xs : List Nat) : String := toString xs78-- Tedious! We want:9-- show 42 → "42"10-- show true → "true"11-- show [1,2,3] → "[1, 2, 3]"The issue: we want show to work on any type, but with type-specific behavior. This is called ad-hoc polymorphism.
Type Classes: The Solution
A type class declares an interface. Types implement that interface via instances:
1-- Define what it means to be "showable"2class Show (α : Type) where3 show : α → String45-- Implement for specific types6instance : Show Nat where7 show n := toString n89instance : Show Bool where10 show b := if b then "true" else "false"1112-- Now we can use show generically13#eval Show.show 42 -- "42"14#eval Show.show true -- "true"Using Type Classes in Functions
Functions can require types to have certain instances:
1-- [Show α] means "α must have a Show instance"2def greet [Show α] (x : α) : String :=3 s!"Hello, {Show.show x}!"45#eval greet 42 -- "Hello, 42!"6#eval greet true -- "Hello, true!"78-- Multiple constraints9def compare [Ord α] [Show α] (x y : α) : String :=10 match Ord.compare x y with11 | .lt => s!"{Show.show x} < {Show.show y}"12 | .eq => s!"{Show.show x} = {Show.show y}"13 | .gt => s!"{Show.show x} > {Show.show y}"Implicit Instance Arguments
Instance arguments are implicit. You can pass them explicitly with @when debugging instance resolution.
1def showTwice [Show α] (x : α) : String :=2 s!"{Show.show x}, {Show.show x}"34#eval showTwice 7Standard Type Classes
ToString
The ToString type class powers string interpolation. Any type with a ToString instance can be embedded ins!"..." strings.
1-- Built-in, used by string interpolation2class ToString (α : Type) where3 toString : α → String45-- This is why s!"x = {x}" works6#eval s!"value: {42}" -- "value: 42"7#eval s!"flag: {true}" -- "flag: true"BEq (Boolean Equality)
The BEq type class enables the ==and != operators. It defines a single methodbeq that returns a Bool.
1class BEq (α : Type) where2 beq : α → α → Bool34-- Enables == operator5#eval 5 == 5 -- true6#eval "a" == "b" -- falseOrd (Ordering)
The Ord type class provides comparison operators. Itscompare method returns an Orderingvalue: .lt, .eq, or .gt.
1class Ord (α : Type) where2 compare : α → α → Ordering34-- Ordering is: .lt, .eq, or .gt5-- Enables <, >, <=, >= operators6#eval compare 5 3 -- Ordering.gt7#eval 5 < 3 -- falseHashable
The Hashable type class is required for using a type as keys in HashMap or elements in HashSet.
1class Hashable (α : Type) where2 hash : α → UInt6434-- Used by HashMap, HashSet5#eval hash "hello" -- 9821865621596011261 : UInt64Functor
Functor provides map for transforming values inside a container:
1class Functor (f : Type → Type) where2 map : (α → β) → f α → f β34-- map transforms the value inside5#eval Functor.map (· + 1) (some 5) -- some 66#eval Functor.map (· + 1) [1, 2, 3] -- [2, 3, 4]78-- Infix operator: <$>9#eval (· * 2) <$> some 10 -- some 2010#eval (· * 2) <$> [1, 2, 3] -- [2, 4, 6]Applicative
Applicative extends Functor with pure and sequential application:
1class Applicative (f : Type → Type) extends Functor f where2 pure : α → f α3 seq : f (α → β) → (Unit → f α) → f β45-- pure wraps a value6#eval (pure 5 : Option Nat) -- some 578-- Apply functions inside containers: <*>9#eval some (· + 1) <*> some 5 -- some 61011-- Note: Lean core does NOT give List an Applicative instance, so12-- [(· * 2), (· * 3)] <*> [1, 2]13-- fails with "failed to synthesize Seq List". Use Option, Except, or IO —14-- or flatMap, which is what the list version would mean anyway:15#eval [(· * 2), (· * 3)].flatMap (fun f => [1, 2].map f) -- [2, 4, 3, 6]Instance Resolution
Lean automatically finds the right instance based on types:
1-- Lean figures out which Show instance to use2#eval Show.show 42 -- Uses Show Nat3#eval Show.show "hi" -- Uses Show String45-- This happens at compile time6def printTwice [Show α] (x : α) : String :=7 let s := Show.show x -- Lean picks the instance8 s ++ ", " ++ sInstances Can Depend on Instances
This is where type classes stop being "interfaces" and become genuinely powerful. An instance can itself require instances, so Lean builds the one you need by composing smaller ones.
1-- "A list is showable IF its elements are showable"2instance [ToString α] : ToString (List α) where3 toString xs := "[" ++ ", ".intercalate (xs.map toString) ++ "]"45-- Now Lean can build ToString for arbitrary nesting, with no extra work:6#eval toString [[1, 2], [3]] -- "[[1, 2], [3]]"7#eval toString [some 1, none] -- needs ToString (Option Nat) tooToString (List MyType). Read the type in the error carefully; it tells you exactly which instance to write.Scoping Instances
Instances are global by default: writing one anywhere makes it visible everywhere that imports the file. That is usually what you want, and occasionally a disaster — two libraries both defining ToString Nat differently would silently change behaviour. Lean offers narrower scopes for that reason.
1namespace Verbose23-- scoped: only active when someone writes "open Verbose"4scoped instance : ToString Bool where5 toString b := if b then "affirmative" else "negative"67end Verbose89#eval toString true -- "true" (the default instance)1011open Verbose in12#eval toString true -- "affirmative" (the scoped one wins here)1314-- local instance: active only for the rest of the current section/file15section16local instance : ToString Unit where17 toString _ := "<unit>"1819#eval toString () -- "<unit>"20endscoped instance for anything opinionated — a formatting choice, a non-obvious ordering, a numeric coercion. A global instance for a type you do not own is a promise that your interpretation is the only reasonable one, and it is very hard for a downstream user to undo.Deriving Instances
Many instances can be auto-generated:
1-- deriving generates instances automatically2structure Point where3 x : Int4 y : Int5 deriving Repr, BEq, Hashable67-- Now these work:8#eval repr (Point.mk 1 2) -- Shows the structure9#eval Point.mk 1 2 == Point.mk 1 2 -- trueDeep Dive: Type Classes vs Interfaces
Type classes are similar to interfaces in OOP but more powerful:
- Retroactive conformance:You can add instances for types you don't own
- Multi-parameter: Classes can involve multiple types
- Associated types: Classes can have type members
- Default implementations: Classes can provide default methods
Where Instances Come From
1-- 1. Built-in instances (Nat, Bool, String, etc.)2#eval (5 : Nat) == 5 -- BEq Nat exists34-- 2. Derived instances5structure RGB where6 r : UInt87 g : UInt88 b : UInt89 deriving BEq1011-- 3. Your own instances12instance : ToString RGB where13 toString c := s!"rgb({c.r}, {c.g}, {c.b})"1415-- 4. Instances from libraries16-- (Importing a library may bring instances into scope)Write a function that logs any value that has a ToString instance.
1def log [ToString α] (x : α) : String :=2 s!"value = {x}"34#eval log 1235#eval log true