Type classes are everywhere in the Scala ecosystem. If you want to learn advanced libraries like Scalaz, Cats or Shapeless you need to know about them. Even if you don’t plan to use these libraries you can (and probably should) use type classes in your own applications. Type classes are borrowed from Haskell and are sometimes called “ad-hoc polymorphism”. If you want to know what that means read on
You can find complete examples of the concepts discussed on my blog in my Github repo . In particular, check out the type classes example
I’ll try to answer a few key questions in this post:
- What’s wrong with “traditional” polymorphism?
- What are type classes and why are they better than static polymorphism?
- What does “composability” mean and why is it important?
While you’re reading this post you may think? “What’s the big deal? I could do all this without type classes”. If so I urge you to read to the end, specifically the section about composability
The good news is that type classes in themselves are pretty simple. Lets get started!
The problem with object oriented polymorphism
Lets start with a simple example:
trait Vehicle {
def drive(): String
}
case class Car(make: String) extends Vehicle {
override def drive(): String = s"driving a $make car"
}
case class Bus(color: String) extends Vehicle {
override def drive(): String = s"driving a $color bus"
}
class Person {
def drive(vehicle: Vehicle): Unit = println(vehicle.drive())
}
We now decide to use a train drivers library. We’d like to drive a Train
but we don’t have the source code for the train,
only the binaries. We can’t modify train to make it extend our Vehicle
so we need to subclass the Train:
class MyTrain extends otherLibrary.Train with Vehicle {
override def drive(): String = "driving a train"
}
It’s a bit nasty but it kind of works. But what if otherLibrary.Train
is declared to be final? We need an adapter:
class TrainAdapter(underlying: otherLibrary.Train) extends Vehicle {
override def drive(): String = "driving a train"
def brake(): Unit = underlying.brake()
def derail(): Unit = underlying.derail()
...
}
Very nasty!
Why not implicit classes
Actually it doesn’t need to be so nasty. Implicit classes, introduced in Scala 2.10 allow us to pimp classes and add our own behaviour:
implicit class MyTrain(underlying: otherLibrary.Train) extends Vehicle {
override def drive(): String = "driving a train"
}
If we have a function which takes a Vehicle we can pass any object to it, so long as we have pimped it to make it a Vehicle:
def driveAndReturn(vehicle: Vehicle): Vehicle = ...
val train: Train = new otherLibrary.Train(...)
val drivenTrain: Vehicle = driveAndReturn(train)
The problem is that drivenTrain
is no longer a Train
, it’s a Vehicle
. We can cast it:
val drivenTrain = driveAndReturn(train).asInstanceOf[otherLibrary.Train]
but it’s unsafe and as Scala developers we want to avoid as many runtime exceptions as possible.
What are type classes
In “classic” object oriented design we describe behaviour in an interface/trait and define concrete implementations of that interface.
In other words we say a Car is a Vehicle
. Type classes approach this from a slightly different angle.
We define a trait which says something of type A
should be capable of being treated as a Vehicle
. We then create concrete
implementations of this contract for each type of Vehicle we are interested in. If it sounds complex, an example will clear it up:
trait VehicleLike[A] {
def drive(a: A): String
}
Notice how we paramaterize the trait and the drive method now takes a parameter of type A
. An implementation for a Car and Bus would look like:
val vehicleLikeCar = new VehicleLike[Car] {
def drive(car: Car): String = s"driving a ${car.make} car"
}
val vehicleLikeBus = new VehicleLike[Bus] {
def drive(bus: Bus): String = s"driving a ${bus.color} bus"
}
we can now drive a Car
and Bus
:
val ford = Car("ford")
val londonBus = Bus("red")
println(vehicleLikeCar.drive(ford))
println(vehicleLikeBus.drive(londonBus))
So what’s the big deal? We’re not finished yet. Let’s make the implementations available implicitly and write a generic method that would like to “drive” something:
implicit val vehicleLikeCar = ...
implicit val vehicleLikeBus = ...
def driveAndReturn[A](vehicle: A)(implicit evidence: VehicleLike[A]): A = {
println(evidence.drive(vehicle)); vehicle
}
Take a moment to think about this, driveAndReturn
can handle anything, it doesn’t care. By convention we name the implicit
parameter “evidence” or “ev”. The method is saying “pass me what you like, but you have to supply evidence that I can drive it”
Whatever type we pass in we get back:
val ford: Car = Car("ford")
// still a Car
val drivenFord: Car = driveAndReturn(ford)
A typical Scala library will define the type class (trait) and functions that use it, VehicleLike
and driveAndReturn
in our case along
with some common implementations e.g. vehicleLikeCar
and vehicleLikeBus
. A typical pattern would be something like:
trait VehicleLike[A] {
def drive(a: A): String
}
object Vehicles {
implicit val vehicleLikeCar = ???
implicit val vehicleLikeBus = ???
}
object Dealership {
def driveAndReturn[A](vehicle: A)(implicit ev: VehicleLike[A]): A = ???
}
callers would use the library by importing the implementations before calling the method in question:
import Vehicles._ // import car and bus implementations
val ford = Car("ford")
Dealership.driveAndReturn(ford) // vehicleLikeCar is already in scope due to the import
But here is the real power - callers of this method are not limited to Cars
and Buses
, they can write their own type class instances:
implicit val vehicleLikeTank = new VehicleLike[Tank] {
def drive: String = "driving a big big tank"
}
val tank = Tank(...)
val drivenTank: Tank = Dealership.driveAndReturn(tank)
Type classes compose
Lets say we want to pass a list of cars to driveAndReturn
we have a few options:
We could modify it to accept a List
of vehicles:
def driveAndReturn[A](vehicles: List[A])(implicit vehicleLike: VehicleLike[A]): List[A] = ...
We could keep the existing driveAndReturn
signature but create a type class implementation for a List of Cars:
implicit val VehicleLikeCarList = new VehicleLike[List[Car]] {
def drive(cars: List[Car]): String =
cars.map(car => s"driving a ${car.make} car").mkString(System.lineSeparator())
}
Neither solution is ideal. Let’s compose two type classes
We will leave the original vehicleLikeCar
unchanged and create a new type class for Lists.
But we won’t create it for List[Car]
but List[A]
. Our new type class will behave in a similar way to driveAndReturn
, it will also
look for a type class implementation for A
:
implicit def vehicleListList[A](implicit ev: VehicleLike[A]) = new VehicleLike[List[A]] {
override def drive(aa: List[A]): String =
aa.map(a => ev.drive(a)).mkString(System.lineSeparator())
}
Notice we use a def not a val. Instead of creating a type class instance we’re actually creating a factory which builds a type class
for List[A]
by first looking for a type class for A
Our driveAndReturn
method stays unchanged but we can now pass a list of cars to it:
val cars = List(Car("ford"), Car("BMW"), Car("VW"))
val drivenCars: List[Car] = driveAndReturn(cars)
But it gets better, as we have a type class for lists and a type class for buses (and tanks!) we can also pass a list of buses to the method. Lets summarise and show all the code together:
trait VehicleLike[A] {
def drive(a: A): String
}
implicit val vehicleLikeCar = new VehicleLike[Car] {
def drive(car: Car): String = s"driving a ${car.make} car"
}
implicit val vehicleLikeBus = new VehicleLike[Bus] {
def drive(bus: Bus): String = s"driving a ${bus.color} bus"
}
implicit def vehicleListList[A](implicit evidence: VehicleLike[A]) = new VehicleLike[List[A]] {
override def drive(aa: List[A]): String =
aa.map(a => evidence.drive(a)).mkString(System.lineSeparator())
}
def driveAndReturn[A](vehicle: A)(implicit evidence: VehicleLike[A]): A = {
println(evidence.drive(vehicle)); vehicle
}
val cars = List(Car("ford"), Car("BMW"), Car("VM"))
val busses = List(Bus("red"), Bus("yellow"))
val drivenFord: Car = driveAndReturn(cars.head)
val drivenCars: List[Car] = driveAndReturn(cars)
val drivenBus: Bus = driveAndReturn(busses.head)
val drivenBusses: List[Bus] = driveAndReturn(busses)
Options - Still with me? Lets create a type class for Option[A]:
implicit def vehicleLikeOption[A](implicit ev: VehicleLike[A]) = new VehicleLike[Option[A]] {
override def drive(a: Option[A]) = a.map(ev.drive).getOrElse("Nothing to drive")
}
val someCar: Option[Car] = Some(Car("ford"))
val noCar: Option[Car] = None
driveAndReturn(someCar)
driveAndReturn(noCar)
Of course we can now also handle optional buses. To recap we can handle:
- Cars
- Buses
- Lists
- Options
Question - Can we handle a List[Option[Car]] ? Lets try:
val listOfOptionalCars: List[Option[Car]] = List(Some(Car("ford")), Some(Car("VW")), None)
driveAndReturn(listOfOptionalCars)
The answer is we can! And we can flip the List and Option and it will still work:
val optionalListOfCars: Option[List[Car]] = Some(List(Car("ford")))
driveAndReturn(optionalListOfCars)
This is composability in action and it’s what makes type classes so powerful. We can dramatically cut down on boilerplate code by letting the scala compiler wire together the pieces we need.
Wrapping up
The signature for driveAndReturn
is a bit verbose as we need to specify the implicit evidence parameter. We can simplify
this a bit by using a context bound and the implicitly keyword:
def driveAndReturn[A: VehicleLike](vehicle: A): A = {
println(implicitly[VehicleLike[A]].drive(vehicle)); vehicle
}
Whether this is actually any more concise or readable is subjective
What next
Functor is a simple type class representing the familiar map method. It’s the perfect place to begin your journey with Cats
Semigroup let’s us combine two values recursively. It’s also quite simple to understand and so is a good place to start