Scala Programming Guidesscala-advancedscala-type-level

Type Classes — Ad-Hoc Polymorphism | Scala Programming Guide

By Dmitri Meshin
Picture of the author
Published on
Type Classes — Ad-Hoc Polymorphism - Scala Programming Guide

What Is a Type Class?

A type class is a pattern for ad-hoc polymorphism: providing different implementations of a behavior for different types, without modifying those types. It's three things:

  1. A trait defining an interface
  2. Instances for specific types
  3. Syntax making instances available implicitly
// 1. Define the trait (the type class itself)
trait Serializable[T] {
  def serialize(value: T): String
}

// 2. Provide instances for specific types
given Serializable[Int] with {
  def serialize(value: Int): String = value.toString
}

given Serializable[String] with {
  def serialize(value: String): String = s"\"$value\""
}

given Serializable[Boolean] with {
  def serialize(value: Boolean): String = value.toString.toLowerCase
}

// 3. Create syntax (extension methods) for convenient usage
extension [T](value: T) {
  def serialize(using ser: Serializable[T]): String = ser.serialize(value)
}

// Now we can serialize any type that has a Serializable instance
println(42.serialize)           // "42"
println("hello".serialize)      // ""hello""
println(true.serialize)         // "true"

Building a Type Class from Scratch: The Printable System

Let's move from theory to practice by building a complete, real-world type class from scratch. This example demonstrates all the components: the trait definition, instances for various types, composition of instances, and syntax to make the type class convenient to use. By the end, you'll understand not just how to use existing type classes, but how to design and implement your own. We'll build a Printable type class that defines how to convert domain objects to human-readable strings—a common requirement in applications. This will serve as a foundation for understanding more complex type classes later.

Type classes enable a powerful design pattern: you can write generic code that works with any type that has an instance of your type class, without coupling to those types. This is fundamentally different from inheritance, where types must explicitly extend your base class. Type classes separate the interface (the trait) from the implementations (the instances), allowing flexible, compositional design. Understanding how to build type classes will help you recognize them in production code and design better abstractions in your own projects.

// A permission system requiring pretty printing
sealed trait AccessLevel
case object Public extends AccessLevel
case object Internal extends AccessLevel
case object Restricted extends AccessLevel

case class User(id: Int, name: String, level: AccessLevel)
case class Document(title: String, content: String, accessLevel: AccessLevel)

// 1. Define the type class
trait Printable[T] {
  def print(value: T): String

  // Default methods can be defined in the trait
  def println(value: T): Unit =
    scala.Predef.println(print(value))
}

// 2. Provide instances for our domain types

given Printable[AccessLevel] with {
  def print(level: AccessLevel): String = level match {
    case Public => "PUBLIC (world readable)"
    case Internal => "INTERNAL (organization only)"
    case Restricted => "RESTRICTED (admin only)"
  }
}

given Printable[User] with {
  def print(user: User): String = {
    // Use the Printable[AccessLevel] instance we defined above
    val levelStr = summon[Printable[AccessLevel]].print(user.level)
    s"User(id=${user.id}, name=${user.name}, access=$levelStr)"
  }
}

given Printable[Document] with {
  def print(doc: Document): String = {
    val levelStr = summon[Printable[AccessLevel]].print(doc.accessLevel)
    val truncatedContent =
      if (doc.content.length > 50) doc.content.take(50) + "..."
      else doc.content
    s"Document(title='${doc.title}', access=$levelStr, content='$truncatedContent')"
  }
}

// 3. Create syntax
extension [T](value: T) {
  def print(using printable: Printable[T]): String = printable.print(value)
  def println(using printable: Printable[T]): Unit = printable.println(value)
}

// Usage
val user = User(1, "Alice", Restricted)
user.println()
// Output: User(id=1, name=Alice, access=RESTRICTED (admin only))

val doc = Document("Security Policy", "All employees must...", Internal)
doc.println()
// Output: Document(title='Security Policy', access=INTERNAL (organization only), content='All employees must...')

Summoning Instances: Explicit Retrieval

Most of the time, type classes are used implicitly—you write code that looks normal, and the compiler wires up the type class instances behind the scenes. But sometimes you need explicit control: you might need to get an instance at runtime, use a specific instance instead of the default, or operate on a type class instance directly. Scala provides the summon function (Scala 3) and implicitly (Scala 2) for this purpose. While reaching for summon is usually a sign that your design could be improved, there are legitimate cases where explicit retrieval is necessary. Understanding how to use summon also helps you debug implicit resolution issues. Let's explore the different techniques for explicit instance retrieval and when each is appropriate.

The ability to summon instances is particularly useful when implementing generic functions where you want to operate on the type class instance itself, or when you need to choose between multiple instances at runtime. It's also valuable for debugging: if implicit resolution seems mysterious, using summon explicitly lets you see exactly what the compiler found. However, relying too heavily on summon is a code smell—it usually means your function signatures aren't clear about their dependencies.

// Continue with our Printable type class from above

// Option 1: use summon (Scala 3 preferred)
def describeType[T](value: T)(using printable: Printable[T]): String = {
  s"Value of type ${value.getClass.getSimpleName} is: ${printable.print(value)}"
}

println(describeType(User(2, "Bob", Public)))

// Option 2: use implicitly (Scala 2 style, still works in Scala 3)
def describeTypeOld[T](value: T)(implicit printable: Printable[T]): String = {
  s"Value of type ${value.getClass.getSimpleName} is: ${implicitly[Printable[T]].print(value)}"
}

// Option 3: manual resolution in specific cases
def printAll[T](values: List[T]): String = {
  // This is dangerous - what if T doesn't have a Printable instance?
  val printer = summon[Printable[T]] // Will fail at runtime if not found
  values.map(printer.print).mkString("\n")
}

// Better: make it clear via the method signature
def printAllSafe[T](values: List[T])(using printer: Printable[T]): String =
  values.map(printer.print).mkString("\n")

val users = List(
  User(1, "Alice", Restricted),
  User(2, "Bob", Public)
)
println(printAllSafe(users))

Composing Type Classes: Deriving Instances

One of the most powerful aspects of type classes is composing them: defining instances in terms of other instances. This is where type classes reveal their true elegance. Instead of writing a Serializable[List[T]] instance for every concrete type T, you can write a single generic instance that says "if T is serializable, then List[T] is serializable." This automatic derivation means your type class library scales without exponential code growth. The mechanism relies on Scala's implicit resolution: when the compiler needs an instance for a complex type, it can construct it from instances of simpler types. This enables powerful abstractions like automatic JSON serialization for nested data structures, and is the foundation of modern Scala libraries like Circe and Play JSON. Understanding how to compose type classes is essential for building reusable, scalable abstractions.

Type class composition is where the power of the pattern becomes apparent. Instead of manually writing instances for every possible type, you write compositional instances that handle entire families of types. This is what makes type classes scale to handle complex domain models without creating an explosion of boilerplate. The compiler's implicit resolution algorithm automatically chains these instances together, creating exactly the instance you need on demand.

// A numeric computation system
trait Summable[T] {
  def zero: T // The identity element
  def add(a: T, b: T): T // The operation
}

given Summable[Int] with {
  def zero = 0
  def add(a: Int, b: Int) = a + b
}

given Summable[Double] with {
  def zero = 0.0
  def add(a: Double, b: Double) = a + b
}

given Summable[String] with {
  def zero = ""
  def add(a: String, b: String) = a + b
}

// Now, derive Summable for List[T] given Summable[T]
given [T](using summable: Summable[T]): Summable[List[T]] with {
  def zero = List(summable.zero) // List containing one zero element

  def add(a: List[T], b: List[T]): List[T] = {
    // Element-wise addition using the T summable instance
    (a zip b).map { case (x, y) => summable.add(x, y) }
  }
}

// This automatically gives us Summable[List[Int]], Summable[List[Double]], etc!
def sum[T](values: List[T])(using summable: Summable[T]): T =
  values.foldLeft(summable.zero)(summable.add)

println(sum(List(1, 2, 3, 4, 5)))                    // 15
println(sum(List(1.5, 2.5, 3.0)))                    // 7.0
println(sum(List("hello", " ", "world")))            // "hello world"
println(sum(List(List(1, 2), List(3, 4))))          // List(4, 6) - element-wise!

// This is incredibly powerful because we defined Summable for List
// once, and it works for all types T that have Summable instances

Real-World Type Classes in Scala

Now that you understand type classes in theory, let's see how they're used in real Scala code. Type classes are everywhere: the standard library uses them (Ordering, Numeric), popular libraries like Cats and Scalaz are built on them, and JSON serialization libraries rely on them. Understanding how production code uses type classes will help you recognize them when reading others' code and will inform your own design decisions. This section shows patterns you'll encounter constantly: custom Ordering instances for domain types, Numeric operations on generic containers, and JSON serialization frameworks. These aren't edge cases—they're the norm in well-designed Scala applications. By seeing how real libraries structure type classes, you'll develop intuition for when and how to introduce them into your own projects.

Type classes in the wild often follow predictable patterns: there's usually a main type class trait, instances for base types, compositional instances for containers, and syntax extensions for ergonomics. Recognizing this pattern helps you understand new code quickly and structure your own code predictably. Production Scala code relies heavily on type classes for precisely the reasons we've discussed: flexibility, composability, and the ability to add functionality without modifying existing code.

// Ordering: compare any type that can be ordered
val ints = List(3, 1, 4, 1, 5, 9, 2, 6)
val intsSorted = ints.sorted // Uses Ordering[Int] implicitly

val strings = List("zebra", "apple", "banana")
val stringsSorted = strings.sorted // Uses Ordering[String] implicitly

// Custom type, custom Ordering instance
case class Task(priority: Int, name: String)

given Ordering[Task] with {
  // Sort by priority (ascending), then by name
  def compare(a: Task, b: Task): Int = {
    val byPriority = a.priority.compare(b.priority)
    if (byPriority != 0) byPriority else a.name.compare(b.name)
  }
}

val tasks = List(
  Task(2, "Testing"),
  Task(1, "Design"),
  Task(1, "Architecture"),
  Task(3, "Deployment")
)
println(tasks.sorted)
// Output: Task(1, Architecture), Task(1, Design), Task(2, Testing), Task(3, Deployment)

// Numeric: perform numeric operations on any numeric type
def double[T](x: T)(using num: Numeric[T]): T = {
  import num._
  x + x // Uses Numeric's + operation
}

println(double(5))     // 10
println(double(3.5))   // 7.0

// Show: convert any type to String in a controlled way
trait Show[T] {
  def show(value: T): String
}

given Show[Int] with {
  def show(value: Int) = f"Int($value)"
}

given Show[String] with {
  def show(value: String) = f"String($value)"
}

extension [T](value: T) {
  def show(using s: Show[T]): String = s.show(value)
}

println(42.show)        // Int(42)
println("hello".show)   // String(hello)

// Eq: type-safe equality (prevents comparing incompatible types)
trait Eq[T] {
  def eq(a: T, b: T): Boolean
}

given Eq[Int] with {
  def eq(a: Int, b: Int) = a == b
}

given Eq[String] with {
  def eq(a: String, b: String) = a == b
}

// This is safe - you can't accidentally compare Int and String
// because there's no Eq[Int, String] instance
def elementsEqual[T](a: T, b: T)(using eq: Eq[T]): Boolean = eq.eq(a, b)

println(elementsEqual(5, 5))            // true
println(elementsEqual("a", "b"))        // false
// elementsEqual(5, "5") // Won't compile - good!

Building a JSON Serializer Type Class

Here's a complete, practical example: a JSON serialization system. This demonstrates all the advanced type class patterns we've discussed: basic instances, compositional instances for containers, and real-world usage. Pay attention to how the type class scales: we define instances for base types and containers, and the compiler automatically handles nesting. This is the pattern used by production libraries.

// A data serialization framework
sealed trait JsonValue
case object JsonNull extends JsonValue
case class JsonBool(value: Boolean) extends JsonValue
case class JsonNumber(value: Double) extends JsonValue
case class JsonString(value: String) extends JsonValue
case class JsonArray(values: List[JsonValue]) extends JsonValue
case class JsonObject(fields: Map[String, JsonValue]) extends JsonValue

// The type class
trait JsonEncoder[T] {
  def encode(value: T): JsonValue
}

// Base instances
given JsonEncoder[Unit] with {
  def encode(value: Unit) = JsonNull
}

given JsonEncoder[Boolean] with {
  def encode(value: Boolean) = JsonBool(value)
}

given JsonEncoder[Int] with {
  def encode(value: Int) = JsonNumber(value.toDouble)
}

given JsonEncoder[Double] with {
  def encode(value: Double) = JsonNumber(value)
}

given JsonEncoder[String] with {
  def encode(value: String) = JsonString(value)
}

// Derived instances for collections
given [T](using encoder: JsonEncoder[T]): JsonEncoder[List[T]] with {
  def encode(values: List[T]) =
    JsonArray(values.map(encoder.encode))
}

given [T](using encoder: JsonEncoder[T]): JsonEncoder[Option[T]] with {
  def encode(opt: Option[T]) = opt match {
    case Some(value) => encoder.encode(value)
    case None => JsonNull
  }
}

// Syntax for convenient usage
extension [T](value: T) {
  def toJson(using encoder: JsonEncoder[T]): JsonValue =
    encoder.encode(value)
}

// User-defined types
case class Author(name: String, email: String)
case class BlogPost(title: String, author: Author, tags: List[String])

// For custom types, you must provide instances manually
// (Scala 3.0 doesn't have automatic derivation; Scala 3.1+ has better support)
given JsonEncoder[Author] with {
  def encode(author: Author) = JsonObject(Map(
    "name" -> author.name.toJson,
    "email" -> author.email.toJson
  ))
}

given JsonEncoder[BlogPost] with {
  def encode(post: BlogPost) = JsonObject(Map(
    "title" -> post.title.toJson,
    "author" -> post.author.toJson,
    "tags" -> post.tags.toJson
  ))
}

// Helper function to pretty-print JSON
def jsonString(value: JsonValue, indent: Int = 0): String = {
  val spaces = "  " * indent
  value match {
    case JsonNull => "null"
    case JsonBool(b) => b.toString
    case JsonNumber(n) => n.toString
    case JsonString(s) => s""""$s""""
    case JsonArray(values) =>
      if (values.isEmpty) "[]"
      else {
        val items = values.map(v => spaces + "  " + jsonString(v, indent + 1))
        "[\n" + items.mkString(",\n") + "\n" + spaces + "]"
      }
    case JsonObject(fields) =>
      if (fields.isEmpty) "{}"
      else {
        val items = fields.map { case (k, v) =>
          spaces + "  " + s""""$k": ${jsonString(v, indent + 1)}"""
        }
        "{\n" + items.mkString(",\n") + "\n" + spaces + "}"
      }
  }
}

// Usage
val author = Author("Jane Doe", "jane@example.com")
val post = BlogPost(
  "Scala Type Classes",
  author,
  List("scala", "programming", "type-systems")
)

println(jsonString(post.toJson))
// Output:
// {
//   "title": "Scala Type Classes",
//   "author": {
//     "name": "Jane Doe",
//     "email": "jane@example.com"
//   },
//   "tags": [
//     "scala",
//     "programming",
//     "type-systems"
//   ]
// }