Skip to Content
Course content

28: Type Classes in Scala

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, you've likely handled polymorphism by defining a trait and having your classes extend it. That works great when you own the code and the relationship is a strict "is-a" relationship. But what happens when you want to add behavior to a class you didn't write—like Int or String—or when you want to keep your domain models completely clean of infrastructure concerns like JSON serialization or database mapping?

That's where type classes come in. In Scala, a type class is essentially a pattern that lets us achieve ad-hoc polymorphism. Instead of saying "a User is a JSON-serializable object," we say "there exists a way to serialize a User to JSON."

Defining the behavior we want

Let's build a simple JSON writer. We don't want to use a library for this; we want to see how the machinery works. First, we define the "contract" for our type class. This is just a trait with a type parameter.

trait JsonWriter[T] {
  def write(value: T): String
}

Notice that JsonWriter isn't something a class inherits from. It's a separate entity that describes a capability for a type T.

The temptation to bake logic into the data

When I first started implementing this in a project a few years back, I did something that felt intuitive but ended up being a nightmare. I tried to make my domain models implement a Serializable trait directly.

trait JsonSerializable {
  def toJson: String
}

case class User(name: String, age: Int) extends JsonSerializable {
  def toJson: String = s"""{"name": "$name", "age": $age}"""
}

At first glance, this is easy. But then I realized I'd just polluted my User model with JSON logic. If I later needed to support XML, Protobuf, and YAML, my User class would become a bloated mess of serialization methods. Worse, I couldn't do this for Int or List because I can't go into the Scala standard library and make Int extend my JsonSerializable trait.

Separating the 'what' from the 'how'

To fix this, we move the implementation outside the class. We create "instances" of our type class for the types we care about. We mark these as implicit so the compiler can find them automatically when needed.

case class User(name: String, age: Int)

// Implementation for String
implicit val stringWriter = new JsonWriter[String] {
  def write(value: String): String = s""""$value""""
}

// Implementation for Int
implicit val intWriter = new JsonWriter[Int] {
  def write(value: Int): String = value.toString
}

// Implementation for User
implicit val userWriter = new JsonWriter[User] {
  def write(user: User): String = 
    s"""{"name": ${stringWriter.write(user.name)}, "age": ${intWriter.write(user.age)}}"""
}

Now, the User class is just a data carrier. It has no idea that JsonWriter even exists. This is much cleaner.

Making it ergonomic with a helper

Writing userWriter.write(myUser) is fine, but it's clunky. We want a way to just say "write this thing" and let the compiler figure out which instance to use. We can do this by creating a helper method that requires an implicit JsonWriter.

object Json {
  def write[T](value: T)(implicit writer: JsonWriter[T]): String = {
    writer.write(value)
  }
}

// Now we can use it like this:
val user = User("Alice", 30)
println(Json.write(user)) // The compiler finds userWriter automatically
println(Json.write("Hello")) // The compiler finds stringWriter automatically

What's happening here is that the compiler looks at the type of value (e.g., User), then searches the implicit scope for a JsonWriter[User]. If it finds one, it plugs it in. If it doesn't, you get a compile-time error. This gives us total type safety without sacrificing the flexibility of our data models.




📋 Practical Task

Implementing a Custom Formatter for a Product Catalog

You are building a catalog system. You have a Product case class and you need a way to format these products for different output targets (e.g., a concise summary for a mobile app and a detailed view for a website).

Your task:

  • Define a type class trait called ProductFormatter[T] with a method format(item: T): String.
  • Create a case class Product(id: Int, name: String, price: Double).
  • Create an implicit instance of ProductFormatter[Product] that returns a string in the format: "Product: [name] - $[price]".
  • Create an implicit instance of ProductFormatter[Int] (to handle formatting just the ID) that returns: "ID: [id]".
  • Implement a helper object Formatter with a method display[T](item: T)(implicit f: ProductFormatter[T]): String that calls the formatter.
  • Test your implementation by calling Formatter.display on both a Product instance and an Int.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.