“A type class is an interface that defines some behavior. More specifically, a type class specifies a bunch of functions, and when we decide to make a type an instance of a type class, we define what those functions mean for that type.”
Source code
The source code for all of the type class lessons is available at the following URL:
The code for this lesson is in the typeclasses.v1_humanlike package. One note about the source code: It contains an extra eatHumanFood
function that isn’t shown in the examples that follow. I include that function in the source code so you can see how to define multiple functions in a type class.
Introduction
The book, Advanced Scala with Cats, defines a type class as a programming technique that lets you add new behavior to closed data types without using inheritance, and without having access to the original source code of those types. Strictly speaking, this isn’t a technique that’s limited to functional programming, but because it’s used so much in the Cats library, I want to show some examples of the approach here, as well as the motivation for the technique.
Cats is a popular FP library for Scala.
Motivation
The authors of Advanced Scala with Cats make an interesting observation about inheritance, and I’ll offer a slight variation of that point.
Given an OOP Pizza
class like this:
class Pizza(var crustSize: CrustSize, var crustType: CrustType) {
val toppings = ArrayBuffer[Topping]()
def addTopping(t: Topping): Unit = { toppings += t }
def removeTopping(t: Topping): Unit = { toppings -= t }
def removeAllToppings(): Unit = { toppings.clear() }
}
If you want to change the data or methods in this OOP approach, what would you normally do? The answer in both cases is that you’d modify that class.
Next, given a modular FP design of that same code:
case class Pizza (
crustSize: CrustSize,
crustType: CrustType,
toppings: Seq[Topping]
)
trait PizzaService {
def addTopping(p: Pizza, t: Topping): Pizza = ???
def removeTopping(p: Pizza, t: Topping): Pizza = ???
def removeAllToppings(p: Pizza): Pizza = ???
}
If you want to change the data or methods in this code, what would you normally do? The answer here is a little different. If you want to change the data, you update the case
class, and if you want to change the methods, you update the code in PizzaService.
These two examples show that there’s a difference in the code you have to modify when you want to add new data or behavior to OOP and functional programming designs.
Type classes give you a completely different approach. Rather than updating any existing source code, you create type classes to implement the new behavior.
In these lessons I’ll show type class examples so you can learn about the technique in general, and more specifically learn about it so you can understand how the Cats library works, since much of it is implemented using type classes.
Scala 2 type classes have three components
Let’s jump into some examples of how to create and use type classes in Scala 2 so you can see how they work.
Type classes consist of three components:
- The type class, which is defined as a trait that takes at least one generic parameter (a generic “type”)
- Instances of the type class for types you want to extend
- Interface methods you expose to users of your new API
Data for the first example
For the first example, assume that I have these existing data types:
sealed trait Animal
final case class Dog(name: String) extends Animal
final case class Cat(name: String) extends Animal
final case class Bird(name: String) extends Animal
Now assume that you want to add new behavior to the Dog
class. Because dogs are well known for their ability to speak like humans, you want to add a new speak
behavior to Dog
instances, but you don’t want to add the same behavior to cats or birds.
If you have the source code for those behaviors, you could just add the new function there. But in this example I’m going to show how to add the behavior using a type class.
Step 1: The type class
The first step is to create a trait that uses at least one generic parameter. In this case, because I want to add a “speak” function, which is a “human like” behavior, I define my trait like this:
trait BehavesLikeHuman[A] {
def speak(a: A): Unit
}
Using the generic type A
lets us apply this new functionality to whatever type we want. For instance, if you want to apply it to a Dog
and a Cat
, you can do that because I’ve left the type generic.
Step 2: Type class instances
The second step of the process is to create instances of the type class for the data types you want to enhance. In my case, because I only want to add this new behavior to the Dog
type, I create only one instance, which I define like this:
object BehavesLikeHumanInstances {
// only for `Dog`
implicit val dogBehavesLikeHuman = new BehavesLikeHuman[Dog] {
def speak(dog: Dog): Unit = {
println(s"I'm a Dog, my name is ${dog.name}")
}
}
}
The key points about this step are:
- I only create an instance of
BehavesLikeHuman
for theDog
type. - I didn’t create instances for
Cat
orBird
because I don’t want them to have this behavior. - I implement the
speak
method as desired for theDog
type. - I tag the instance as
implicit
so it can be easily pulled into the code that I’ll write in the next steps. - I wrap the code in an
object
, primarily as a way to help me organize it. This isn’t too important in a small example, but it’s helpful in larger, real-world applications.
Step 3: The API (interface)
In the third step of the process you create the functions that you want consumers of your API to see. There are two possible approaches in this step:
- Define a function in an
object
, just like the “Utils” approach I described in the domain modeling lessons - Define an
implicit
function that can be invoked on aDog
instance
I refer to these approaches as options 3a and 3b, and for consumers of these approaches, their code will look like this:
BehavesLikeHuman.speak(aDog) //3a
aDog.speak //3b
I’ll show how to implement these approaches next, but to be clear, you don’t have to implement both approaches. They are two different options — competitive approaches.
Option 3a: The Interface Objects approach
In the Advanced Scala with Cats book, the authors refer to approach 3a as the “Interface Objects” approach. I refer to this as the “explicit” approach because it uses functions in objects, just like the “Utils” approach I described in the Domain Modeling lessons.
For my dog example, I just define a speak
function in an object
, like this:
object BehavesLikeHuman {
def speak[A](a: A)(implicit behavesLikeHumanInstance: BehavesLikeHuman[A]): Unit = {
behavesLikeHumanInstance.speak(a)
}
}
Because speak
can be applied to any type, I still need to use a generic type to define the function. The function also expects an instance of BehavesLikeHuman
to be in scope when the function is executed, and that instance is pulled into the function through the implicit parameter in the second parameter group.
As a consumer, you use this 3a approach as follows. First, import the dogBehavesLikeHuman
instance:
import BehavesLikeHumanInstances.dogBehavesLikeHuman
Remember that it contains a speak
method that’s implemented for a Dog
. Next, create a Dog
instance:
val rover = Dog("Rover")
Finally, you can apply the BehavesLikeHuman.speak
function to the rover
instance:
BehavesLikeHuman.speak(rover)
That results in this output:
I'm a Dog, my name is Rover
That’s the summary of the complete approach using Option 3a. As a final point, notice that you can also manually pass the dogBehavesLikeHuman
instance into the second parameter group:
BehavesLikeHuman.speak(rover)(dogBehavesLikeHuman)
That’s not necessary, because the parameter in the second parameter group is defined as an implicit
variable, but I wanted to show that you can also pass the type in manually, if you prefer.
Notice that the final result of this approach is that you have a new function named speak
that works for the Dog
type. This is nice, but it also seems like a lot of work to create a “Utils” function you can apply to a Dog
. In my opinion, Option 3b is where all of this work really pays off.
Option 3b: The Interface Syntax approach
As an alternative to Option 3a, you can use a second approach that the Advanced Scala with Cats book refers to as the “Interface Syntax” approach. The keys of this approach are:
- In the end, it lets you call your new function as
dog.speak
- The Cats book refers to the methods you create as “extension methods,” because they extend existing data types with the new methods
- The Cats project refers to this as “syntax” for the type class
As a quick review, in Step 1 of the process I created a trait that uses a generic type:
trait BehavesLikeHuman[A] {
def speak(a: A): Unit
}
Then in Step 2 I created an instance of the type class for the Dog
data type:
object BehavesLikeHumanInstances {
// only for `Dog`
implicit val dogBehavesLikeHuman = new BehavesLikeHuman[Dog] {
def speak(dog: Dog): Unit = {
println(s"I'm a Dog, my name is ${dog.name}")
}
}
}
Now in Step 3b, I create the new “interface syntax” like this:
object BehavesLikeHumanSyntax {
implicit class BehavesLikeHumanOps[A](value: A) {
def speak(implicit behavesLikeHumanInstance: BehavesLikeHuman[A]): Unit = {
behavesLikeHumanInstance.speak(value)
}
}
}
Consumers of this approach will write their code as follows. First, they import the dogBehavesLikeHuman
instance as before:
import BehavesLikeHumanInstances.dogBehavesLikeHuman
Then they import the implicit
class from inside the BehavesLikeHumanSyntax
object:
import BehavesLikeHumanSyntax.BehavesLikeHumanOps
Next, they create a Dog
instance as usual:
val rover = Dog("Rover")
Finally, the big difference between options 3a and 3b is that with Option 3b, they can invoke your methods directly on the rover
instance, like this:
rover.speak
This shows the benefit of all of the work leading up to this point: The Dog
instance has a new speak
method. If you didn’t have access to the original Dog
source code, this would be a huge win. More generally, it shows a three-step process you can use to add new functionality to any “closed” class.
Note: As I mentioned earlier, the source code for this lesson has an additional
eatHumanFood
function that shows how to define multiple functions in a type class.
Key points
As demonstrated, a type class consists of three components:
- The type class itself, which is defined as a trait that takes at least one generic parameter
- Instances of the type class for the data types you want to extend
- Interface methods you expose to users of your new API
The benefits of using a type class are:
- It provides an approach that lets you add new behavior to existing classes without using traditional inheritance, especially in the case where you can’t (or don’t want to) modify the existing source code of existing data types
What’s next
In the next lesson I’ll provide a more practical example of using a type class, using the source code from the pizza classes from the Domain Modeling lessons.