06 · Type Classes¶
Monoid and Functor from the previous module are both examples of a
broader design pattern called a type class: define a trait describing a
capability, provide given instances for the types that have it, and write
functions generic over "any type with this capability" — without touching
the original type's definition at all. This module names the pattern
explicitly and works through building one from scratch.
The problem type classes solve¶
Suppose you want a show operation — a custom, controllable
toString-like string representation — for Int, Boolean, and your own
Point class. You can't add methods to Int or Boolean (you don't own
them), and even for Point, baking show in as a member method means
every type needing it must be modified individually, with no shared
contract a generic function could rely on.
Defining a type class¶
A type class is just a trait parameterized by the type it describes:
trait Show[A]:
def show(a: A): String
object Show:
def apply[A](using s: Show[A]): Show[A] = s // summon helper
given Show[Int] with
def show(a: Int): String = a.toString
given Show[Boolean] with
def show(a: Boolean): String = if a then "yes" else "no"
Show.apply (often called a "summoner") lets you write Show[Int] to pull
the given Show[Int] instance out of implicit scope by name — useful inside
generic functions.
Writing generic code against the type class¶
def printAll[A: Show](xs: List[A]): Unit =
xs.foreach(x => println(Show[A].show(x)))
printAll(List(1, 2, 3))
// 1
// 2
// 3
printAll(List(true, false))
// yes
// no
[A: Show] is a context bound — sugar for [A](using Show[A]).
printAll never mentions Int or Boolean; it only needs some Show[A]
to exist, supplied automatically by the compiler at each call site.
Adding an instance for your own type — no inheritance needed¶
This is the pattern's real payoff: giving Point a Show instance doesn't
require Point to extend anything or know Show exists:
case class Point(x: Int, y: Int)
given Show[Point] with
def show(p: Point): String = s"(${p.x}, ${p.y})"
printAll(List(Point(0, 0), Point(3, 4)))
// (0, 0)
// (3, 4)
Compare this to putting show on Point directly (intrusive, and
impossible for types you don't control like Int) or to an
overloaded-methods approach (show(x: Int), show(x: Boolean), ... — no
shared abstraction a generic function like printAll could target).
Extension methods for a nicer call site¶
Show[A].show(x) works but reads backwards. An extension combined with a
using clause gives instances a natural x.show syntax:
extension [A](a: A)(using s: Show[A])
def show: String = s.show(a)
println(Point(1, 2).show) // (1, 2)
Now any type with a given Show[_] instance gets .show for free — this
is exactly how the standard library gives you xs.sorted (via
Ordering[A]) and xs.sum (via Numeric[A]) on generic collections.
The trap: ambiguous or missing instances¶
If two given Show[Int] instances are in scope at once, the compiler
reports an ambiguity error at the call site using them — not where the
duplicate was defined — which can be confusing in a large codebase with
instances scattered across files. And if no instance exists for a type you
call printAll on, you get a compile error ("no given instance of type
Show[X] was found") rather than a runtime failure — this is a feature
of type classes (missing behavior is caught at compile time), but the error
message is easy to misread as "context bound syntax is broken" when the
real issue is simply a missing given.
How It Actually Works¶
A type class method call like show(42) (where show requires using
Show[Int]) compiles down to ordinary parameter passing — nothing about
it is dynamic dispatch on a value's runtime class the way interface
inheritance is. The compiler performs implicit resolution entirely at
compile time: at the call site, it searches (in order) the local scope,
then explicitly imported implicits, then the implicit scope of the
type involved — which includes the companion objects of Show itself and
of the type being shown — for a value of type Show[Int]. Once found, it
rewrites show(42) into show(42)(givenShowInt), passing the found
instance as a completely ordinary extra method argument. This is why
"adding an instance for your own type — no inheritance needed" works: the
compiler doesn't care how MyType was declared, only whether a
Show[MyType] value is findable by this search at the call site — the
type class instance and the type it describes can live in totally
unrelated code, unlike interface implementation which requires the type
itself to declare extends.
"Ambiguous or missing instances" are both compile-time failures of this
exact search: missing means the search came up empty across every scope
checked; ambiguous means it found two equally-specific candidates with no
tiebreaker (companion-object implicits placed to be "more specific" than
imported ones is a deliberate priority rule in the search, precisely to
reduce accidental ambiguity). Because the whole thing resolves before your
program runs, there's no runtime cost analogous to a virtual method
table lookup or instanceof check — the "dispatch" is baked into the
compiled bytecode as a specific instance reference passed at the call
site.
Extension methods (42.showit calling Show[Int]'s logic) compile via
an implicit conversion or Scala 3's extension syntax into essentially
the same rewrite: 42.showit becomes a call to a synthesized wrapper
whose only job is to forward to Show[Int].show(42) — the "nicer call
site" is purely a compile-time rewrite, with the actual work still
happening inside the type class instance's method body.
Cheat sheet¶
| Need to... | Use |
|---|---|
| Define a capability as a type class | trait TypeClass[A] with the operations |
| Provide an implementation for a type | given TypeClass[SomeType] with ... |
| Summon an instance by name | summon[TypeClass[A]] or a custom apply in the companion |
| Write code generic over "has this capability" | def f[A: TypeClass](...) (context bound) |
| Give instances a natural method-call syntax | extension [A](a: A)(using tc: TypeClass[A]) def op = ... |
| Add capability to a type you don't own | define a given for it — no inheritance required |
Exercise¶
Define a Ord[A] type class with def compare(x: A, y: A): Int (negative
if x < y, zero if equal, positive if x > y), provide given instances
for Int and String, and write def maxOf[A: Ord](xs: List[A]): A that
finds the largest element using only the type class (no .max from the
standard library). Add an extension method def isGreaterThan[A](other:
A)(using ord: Ord[A]): Boolean and use it to compare two Points by
distance from the origin (you'll need a given Ord[Point] computing
x*x + y*y for each side). Confirm maxOf works on both List[Int] and
List[String].