Type Classes in Scala: Ad Hoc Polymorphism Explained From First Principles
Suppose you need to define a “can be serialized to JSON” capability for Int, String, a third-party library’s DateTime class you don’t own, and your own User case class — all without modifying any of their source code, and without them sharing any common inheritance hierarchy. Traditional OOP inheritance can’t solve this (you can’t retroactively make Int extend an interface). This is exactly the problem type classes solve, and they’re built from nothing more than the traits and implicits/given machinery already covered in this series — no new language feature required.
The Problem With Inheritance-Based Polymorphism
// The inheritance approach — requires modifying (or wrapping) every type that needs the capabilitytrait Serializable { def serialize: String}
class User(name: String) extends Serializable { def serialize: String = s"""{"name":"$name"}"""}This works fine for User, which you own and can freely modify to extends Serializable. It cannot work for Int, String, or a third-party DateTime class — you don’t own their source, can’t add extends Serializable to them, and Scala (correctly) doesn’t let you retroactively bolt inheritance onto a type defined elsewhere. This is the actual, concrete limitation type classes are designed around: adding capability to a type without owning or modifying it.
The Type Class Pattern, Built From a Trait Plus Implicits
// Step 1: define the CAPABILITY as a trait, parameterized over the type it applies totrait Serializable[T] { def serialize(value: T): String}
// Step 2: provide INSTANCES of that capability for each specific type — this is the "ad hoc" partobject Serializable { given Serializable[Int] with def serialize(value: Int): String = value.toString
given Serializable[String] with def serialize(value: String): String = s"\"$value\""
given Serializable[User] with def serialize(value: User): String = s"""{"name":"${value.name}"}"""}
// Step 3: write generic code that REQUIRES the capability, without knowing the concrete type upfrontdef toJson[T](value: T)(using s: Serializable[T]): String = s.serialize(value)
case class User(name: String)
toJson(42) // "42"toJson("hello") // "\"hello\""toJson(User("Alice")) // {"name":"Alice"}Nothing about Int, String, or User was modified — the capability is provided entirely externally, as a separate given/implicit instance associated with each type, and toJson requires only that some Serializable[T] instance exists for whatever T it’s called with. This is why the pattern is called ad hoc polymorphism: the “polymorphism” (one function working across many types) is achieved not through a shared inheritance hierarchy, but through an ad hoc collection of independently-defined instances, one per type, assembled after the fact.
Type Classes You’ve Already Been Using
val nums = List(3, 1, 4, 1, 5)nums.sorted // uses an implicit/given Ordering[Int] under the hood
case class Employee(name: String, salary: Double)given Ordering[Employee] = Ordering.by(_.salary)
val employees = List(Employee("Bob", 65000), Employee("Alice", 72000))employees.sorted // now works, because an Ordering[Employee] instance is in scopeOrdering[T] — used throughout the collections and generics guides — is itself a type class: the standard library provides instances for Int, String, and other common types, and .sorted requires (via a context bound, def sorted[B >: A](using ord: Ordering[B])) that a matching instance exists. This is precisely why List(3,1,4).sorted works without any argument — the compiler is silently supplying the Ordering[Int] instance, exactly as toJson above was silently supplied a Serializable[T] instance.
Scala’s Show-style pattern (previewed in the implicits and given/using guides), Numeric[T] (backing generic arithmetic across Int/Double/BigDecimal), and JSON libraries’ Encoder[T]/Decoder[T] are all the identical pattern applied to different capabilities.
Type Class Syntax — Making Call Sites Read Naturally
trait Serializable[T]: def serialize(value: T): String
object Serializable: given Serializable[Int] with def serialize(value: Int): String = value.toString
// Extension methods, added specifically so call sites read as value.toJson instead of toJson(value)extension [T](value: T) def toJson(using s: Serializable[T]): String = s.serialize(value)
42.toJson // "42" — reads naturally, as if Int itself had a .toJson methodCombining a type class with an extension method (or, in Scala 2, an implicit class) is what makes call sites read like the capability is a native method on the type — 42.toJson rather than toJson(42) — even though Int was never modified and knows nothing about Serializable. This combination (trait + given instances + extension syntax) is precisely the full shape of a well-designed, ergonomic type class in idiomatic modern Scala.
Building a Realistic Type Class: Equality Without Overriding equals
trait Eq[T]: def eqv(a: T, b: T): Boolean
object Eq: def apply[T](using eq: Eq[T]): Eq[T] = eq
given Eq[Int] with def eqv(a: Int, b: Int): Boolean = a == b
given caseInsensitiveStringEq: Eq[String] with def eqv(a: String, b: String): Boolean = a.toLowerCase == b.toLowerCase
extension [T](a: T) def ===(b: T)(using eq: Eq[T]): Boolean = eq.eqv(a, b)
42 === 42 // true"Hello" === "HELLO" // true — using the case-insensitive Eq[String] instance specificallyThis is a genuinely useful, real pattern: Scala’s built-in == always uses each type’s own equals method (structural for case classes, reference-based otherwise, as covered in the classes guide) — you can’t swap in different equality semantics for the same type in different contexts using == alone. A custom Eq[T] type class lets you define (and switch between) different notions of equality — case-sensitive vs. case-insensitive string comparison, for instance — entirely independent of what equals does, chosen explicitly per use case rather than baked permanently into the type.
Type Classes vs Inheritance-Based Polymorphism — When to Choose Which
| Type class | Inheritance (trait/abstract class) | |
|---|---|---|
| Requires owning/modifying the type | No — instances defined entirely separately | Yes — the type itself must extends/implements |
Can add a new capability to Int, String, third-party types | Yes | No |
| Multiple, swappable implementations for the same type | Yes — different given instances for different contexts | No — a type has exactly one inheritance relationship to a given trait |
| Adding a new TYPE that needs an existing capability | Write one new instance | Modify the type to extend the trait |
| Adding a new CAPABILITY across many existing types | Write one new type class + instances per type | Modify every existing type to implement the new trait — often impossible for types you don’t own |
Choose inheritance when the capability is fundamental to what the type is, and you own every type that needs it. Choose a type class when the capability is something you want to add after the fact, potentially to types you don’t own, or when the same type needs multiple different behaviors for the same capability depending on context (like the case-sensitive vs. case-insensitive Eq[String] example).
Context Bound Shorthand for Type Classes
// Explicit using clausedef toJson[T](value: T)(using s: Serializable[T]): String = s.serialize(value)
// Context bound — identical meaning, more concisedef toJsonShort[T: Serializable](value: T): String = summon[Serializable[T]].serialize(value)Exactly as covered in the implicits and given/using guides, T: Serializable is shorthand for a using/implicit parameter requiring a Serializable[T] instance — this shorthand is used constantly in real type class code specifically because “generic function requiring a type class instance” is the type class pattern’s single most common shape.
Coherence — Why Type Class Instances Should Be Unique Per Type
// Two conflicting Ordering[Employee] instances, both in scope — a coherence problemgiven byName: Ordering[Employee] = Ordering.by(_.name)given bySalary: Ordering[Employee] = Ordering.by(_.salary)
employees.sorted // Ambiguous given instances — compiler can't pick one automaticallyA type class is meant to represent the canonical way a capability applies to a type — having two equally-prioritized instances for the same type in the same scope is a design smell (called an incoherence), and Scala’s compiler correctly refuses to silently guess, failing with an ambiguity error instead. The idiomatic fix, shown in the Eq[String] example, is to make one instance the default (typically placed in the type class’s own companion object, matching the “canonical location” convention from the implicits guide), and require alternate orderings to be explicitly imported or passed by name (employees.sorted(using byName)) rather than left ambient and competing.
// Passing a non-default instance explicitly, without making it ambientval byNameOrder: Ordering[Employee] = Ordering.by(_.name)employees.sorted(using byNameOrder) // explicit — no ambiguity, no need to remove the "default" givenCommon Pitfalls
| Symptom | Likely cause |
|---|---|
| ”no given instance found” for your own type class | An instance for that specific type hasn’t been defined anywhere in scope — add one, typically in the type class’s companion object |
| Ambiguous given instances for the same type class | Two instances of equal priority exist simultaneously — see the coherence discussion above; keep one canonical default and pass alternates explicitly |
Extension method syntax (value.toJson) doesn’t resolve | The extension block granting that syntax needs to be imported/in scope — the underlying type class instance being present isn’t enough on its own |
Type class instance for a generic type (like List[T]) doesn’t derive automatically | Missing the “instance depends on another instance” pattern from the given/using guide — a given [T](using Serializable[T]): Serializable[List[T]] needs to be defined explicitly |
| Confusing which instance actually got picked | Use your editor’s “show implicit/given resolution” feature, or temporarily make the instance non-anonymous with a distinct name to trace it during debugging |
Frequently Asked Questions
Is a type class the same thing as an interface in Java? No — a Java interface requires the implementing class to declare implements InterfaceName itself, at the type’s own definition. A type class instance is defined entirely separately from the type it applies to, which is exactly what makes it possible to add capabilities to types you don’t own or control.
Do I need given/using (or implicits) to build a type class, or is there a separate mechanism? No separate mechanism exists — type classes in Scala are built entirely from an ordinary generic trait plus the given/using (or classic implicit) system covered in the previous two guides. There’s no dedicated “type class” keyword; it’s a design pattern assembled from existing language features.
Why does the standard library’s Ordering/Numeric pattern matter for understanding type classes? Because they’re real, everyday type classes you’ve likely already used without necessarily recognizing the pattern — seeing .sorted work “automatically” across different types is the exact mechanism a custom type class like Serializable[T] relies on.
Can a type class have more than one instance for the same type? Yes, and this is one of type classes’ genuine advantages over inheritance — as shown with Eq[String], you can define multiple, differently-named instances and choose which one to bring into scope for a particular context, something a single fixed equals method could never offer.
Is the type class pattern overkill for small projects? For a handful of types needing one straightforward capability, plain methods or simple inheritance may well be simpler and sufficient. Type classes earn their complexity specifically once you need to add a capability across types you don’t control, or need multiple swappable implementations of the same capability — recognizing that threshold is more valuable than defaulting to type classes everywhere.
What’s Next
Type classes are ad hoc polymorphism, assembled entirely from traits and the given/implicit machinery covered in the previous two guides — genuinely one of Scala’s most powerful design patterns once the underlying pieces click. Next: Futures and Concurrency in Scala, shifting from the type system into asynchronous, concurrent programming — Future, ExecutionContext, and composing async operations safely.