If you looked at the source code for the Scala/FP 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.
Scala 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 using Scala 2, 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 Scala 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
andOrder
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)