Scala Type Classes 101: Introduction

This is a page from my book, Functional Programming, Simplified

“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.”

Learn You a Haskell for Great Good!

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 FP 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.

Type classes have three components

Let’s jump into some examples of how to create and use type classes 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 the Dog type.
  • I didn’t create instances for Cat or Bird because I don’t want them to have this behavior.
  • I implement the speak method as desired for the Dog 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 a Dog 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.

books i’ve written