Type Classes 102: The Pizza Class

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

If you looked at the source code for the Domain Modeling lessons you probably noticed that I said one thing, but did another. I said that I liked the approach of not including any methods in my case classes, but then I wrote this code:

case class Pizza (
    crustSize: CrustSize,
    crustType: CrustType,
    toppings: Seq[Topping]
) {
    override def toString =
        s"""
        |  Pizza ($crustSize, $crustType), toppings = $toppings""".stripMargin
}

I overrode the toString method in both the Pizza and Order classes because I wanted to control how they looked when they were printed out. This shows a conflict of that situation:

  • I want to declare my data types with plain case classes (with no behaviors)
  • I also want to control what those data types look like when they are printed

There are several ways to resolve this conflict, and in this lesson I’ll show how to use type classes so (a) I can declare my data types without any methods, and (b) still get the output I want.

Source code

The source code for this lesson is in the same repository as the previous lesson:

The code for this lesson is in the typeclasses.v2_pizza2string package of that project.

The solution

To implement the solution, I first define the Pizza class the way I really want it, as a simple declaration of its data types without any methods:

case class Pizza (
    crustSize: CrustSize,
    crustType: CrustType,
    toppings: Seq[Topping]
)

Next, I’ll implement a type class that lets me get the printed output I want. I’ll follow the same three-step approach I showed in the previous lesson:

  • Define the type class as a trait that takes at least one generic parameter
  • Define an instance of the type class for the Pizza class
  • Create interface methods that I’ll expose to consumers of this code

Step 1: Define the type class

First, I create a type class named ToString. It takes a generic parameter, and defines an abstract method:

trait ToString[A] {
    def toString(a: A): String
}

Step 2: Define an instance of the type class for the Pizza class

Next, I define an instance of the type class named pizzaAsString that overrides the toString method to declare the way I want a Pizza to be printed:

implicit val pizzaAsString = new ToString[Pizza] {
    def toString(p: Pizza): String = {
        s"""|Pizza(${p.crustSize}, ${p.crustType}),
            |      toppings = ${p.toppings}""".stripMargin
    }
}

Step 3: Create interface methods to make available to consumers of this code

Finally, I create the code that I want consumers of my API to use. In the source code for this lesson I show code for both Option 3a and Option 3b, but in this text I’ll only use the code for Option 3b, which looks like this:

object ToStringSyntax {
    implicit class ToStringOps[A](value: A) {
        def asString(implicit toStringInstance: ToString[A]): String = {
            toStringInstance.toString(value)
        }
    }
}

Step 4: Using the API

With that code in place, I can write some test code as follows. First, I import what I need:

import ToStringInstances.pizzaAsString
import ToStringSyntax._

Next, I create a Pizza instance:

val p = Pizza(
    LargeCrustSize, 
    ThinCrustType, 
    Seq(Cheese, Pepperoni, Sausage)
)

Finally, I print the Pizza instance using the asString method I defined in my type class:

println(p.asString)

This results in the following output:

Pizza(LargeCrustSize, ThinCrustType), 
      toppings = List(Cheese, Pepperoni, Sausage)

Discussion

In this example I followed the steps from the previous lesson, so I won’t go over them in detail. One thing I will mention is that I intentionally named the method in the ToString trait toString:

trait ToString[A] {
    def toString(a: A): String
        --------

And then in Step 3b I intentionally named my API method asString:

object ToStringSyntax {
    implicit class ToStringOps[A](value: A) {
        def asString(implicit toStringInstance: ToString[A]): String = {
            --------

I did this to show you that the method name in your public API — the asString method in ToStringOps — doesn’t have to match the method name in the type class trait. You are more than welcome to keep those method names consistent, but as this example shows, they don’t have to be the same.

The second thing to notice with this approach is that to get the “to string” effect, you need to call asString when printing a pizza:

println(p.asString)
          --------

This isn’t quite as convenient as overriding the toString method in the Pizza class, but because I like to keep my case class definitions clean of any methods, I prefer this approach in this situation.

Work with the source code

I encourage you to work with the source code for this project to understand this technique. In the code you’ll find two packages under the root typeclasses package:

  • v2_pizza2string contains the source code I just showed, and includes code for both options 3a and 3b
  • v3_order2string shows the same approach applied to both the Pizza and Order case classes

The code in v3_order2string shows a minor adjustment I had to make to the process because an order contains one or more pizzas, and I want to avoid duplicating code. See the pizzaAsAStringHelper method in the ToStringInstances object for those details.

Other approaches

It’s important to note that if I want to keep my case classes clean, I can also put these functions in a “Utils” class:

object PizzaUtils {
    def pizzaToString(p: Pizza): String = ???
    def orderToString(o: Order): String = ???
}

and then print like this:

println(PizzaUtils.pizzaToString(pizza))
println(PizzaUtils.orderToString(order))

But in the larger scheme of things, I’m also trying to help you get ready to use the type classes in the Cats project.

Key points of type classes

The key point of this lesson is that this approach lets me cleanly define my case classes using only the data types:

case class Pizza (
    crustSize: CrustSize,
    crustType: CrustType,
    toppings: Seq[Topping]
)

case class Order (
    pizzas: Seq[Pizza],
    customer: Customer
)

and it also gives me a way to print them in a readable format using the asString method that appears to be defined directly on the Pizza and Order classes:

println(pizza.asString)
println(order.asString)

books i’ve written