Scala Tutorial - Type Class For Ad-hoc Polymorphism
Overview
In this tutorial, we will learn about a general pattern that is commonly known as a Type Class that can help you in ad-hoc polymorphism. Before, we start our discussion, it is worth mentioning that a Type Class should not be confused with a class with typed parameters, such as the one which we illustrated in our earlier tutorial from chapter 4. Instead, we will build on the previous materials that we've covered up to now on classes, objects, and traits. The materials on traits are especially important, and feel free to review them before proceeding.
- Learn How To Create And Extend Trait
- Learn How to Create Trait With Type Parameters
- Learn How To Extend Multiple Traits
- Learn How to Use Traits for Dependency Injection Part 1
- Learn How To Use Trait For Dependency Injection Part 2 Avoid Cake Pattern
- Traits, Companion Objects, Factory Pattern
In addition, we've also covered the use of an implicit class to augment a particular type, without having to modify its original source code. For instance, let us consider using an implicit class approach to add a uuid method to a given Donut type.
object DonutImplicits {
implicit class AugmentedDonut(donut: Donut) {
def uuid: String = s"${donut.name} - ${donut.productCode.getOrElse(12345)}""
}
}
What if we wanted to add the uuid method generically to any type, as opposed to just the Donut type? That is where a Type Class becomes handy, and is very much in favor of ad-hoc polymorphism. Truth be told, you are somewhat already familiar with the constructs that make up a conventional Type Class from the materials that we’ve covered up to now. In particular, these include the use of the following: (1) trait, (2) singleton object, (3) companion object, (4) apply() method, (5) implicit class, (6) implicit values, and (7) type parameters.
In this section, we therefore present a comprehensive guide for bringing together the above- mentioned Scala constructs to form a Type Class. In addition, we’ll mirror the respective style and closures from the cats library - a popular library from the wider Scala ecosystem that provides a wide range of functional abstractions. Besides, we’ve written this section in a verbose way so as to take you step-by-step through better understanding this influential feature as it can be very impactful in your day-to-day Scala coding.
Steps
1. Define some custom types to model your particular domain
Let’s start by first creating some custom types to represent certain domain objects. To this end, we define two types, namely, a Donut and a Cupcake, and these make use of the convenient case class.
case class Donut(name: String, price: Double, productCode: Option[Long] = None)
case class Cupcake(name: String)
2. Use a trait as the contract for a given feature
As we’ve seen earlier in this chapter, we can use a trait as a contract for a given feature. This strategy is equivalent to using an interface in, say, Java, or C#.NET. That being so, we create a trait UniversalId that defines an abstract method named uuid. Since our intent is to add the uuid method to any given type, the trait accepts a type parameter of T. And, of course, the input parameter for the uuid method is also of type T.
trait UniversalId[T] {
def uuid(t: T): String
}
3. Create the Companion Object and apply() method for the trait
Next, we create the corresponding Companion Object for that above-mentioned trait - that is, object UniversalId { ... }. When it comes to the apply() method, let’s examine each part one at a time, as it can perhaps feel overwhelming at first sight. Likewise to the trait UniversalId[T] which defines a type parameter, the apply[T] method also exemplifies a type parameter. The return type should be straightforward enough, as it basically needs to match the UniversalId of type T - that is, : UniversalId[T].
The most important bit, however, is that we provide an implicit parameter of type UniversalId[T]. And, that is in fact the actual output of the apply method as denoted by = id, where id is the name for the implicit parameter - that is, implicit id: UniversalId[T]. The implicit parameter is in this spot so as to trigger a search by the compiler for implicit values that match to Universal[T] types, as we illustrate shortly in Step 4.
object UniversalId{
def apply[T](implicit id: UniversalId[T]): UniversalId[T] = id
}
4. Create implicit instances for your relevant types
Subsequently, we can create objects, or instances, for the types that will provide the features of trait UniversalId[T]. Most importantly though, these objects, or instances, are labeled with the implicit val keywords. This has the opposite effect to Step 3, where we lift values into the implicit scope, and leave it to the compiler to match to the corresponding UniversalId[T] type. As a reminder, it is best to further annotate implicit values with their corresponding types, such as, implicit val donutId: UniversalId[Donut], and implicit val cupcakeId: UniversalId[Cupcake].
We also lean on the style from the popular cats218 library, and provide a helper function - that is, def instance[T](func: T => String): UniversalId[T] - to reduce boilerplate code when creating the relevant objects, or instances, of trait UniversalId[T]. Moreover, we use a top-level Singleton Object - that is, object instances { } - to act as a closure in order to facilitate import statements, such as, import UniversalId.instances.donutId, or import UniversalId.cupcakeId, or import UniversalId.instances._.
object UniversalId {
def apply[T](implicit id: UniversalId[T]): UniversalId[T] = id
object instances {
def instance[T](func: T => String): UniversalId[T] =
new UniversalId[T] {
override def uuid(t: T): String = func(t)
}
implicit val donutId: UnversalId[Donut] =
instance(donut => s"${donut.name} - ${donut.name.hashCode}")
implicit val cupcakeId: UniversalId[Cupcake] =
instance(cupcake => s"${cupcake.name} - ${cupcake.name.hashCode}")
}
}
5. Use an Implicit Class to wire the uuid method from Step 2
Finally, we make use of an implicit class to expose a def uniqueId method that will trigger the uuid method of trait UniversalId[T]. Most definitely, we could have exposed a similar def uuid method, but you can think of this implicit class as an opportunity to provide a business, or domain, friendly method name that is relevant to your particular application, or use case.
Notice also that the implicit class UniversalIdOps makes use of Context Bound with the syntax [T: UniversalId]. Its role is essentially to enforce that a required implicit value of type UniversalId[T] is in scope. Similar to Step 4 with closure for the instances, we follow the cats library style and provide a top-level Singleton Object - that is, object ops - in order to facilitate import statements, such as, import UniversalId.ops._.
object UniversalId {
def apply[T](implicit id: UniversalId[T]): UniversalId[T] = id
object instances {
def instance[T](func: T => String): UniversalId[T] =
new UniversalId[T] {
override def uuid(t: T): String = func(t)
}
implicit val donutId: UnversalId[Donut] =
instance(donut => s"${donut.name} - ${donut.name.hashCode}")
implicit val cupcakeId: UniversalId[Cupcake] =
instance(cupcake => s"${cupcake.name} - ${cupcake.name.hashCode}")
}
object ops {
implicit class UniversalIdOps[T: UniversalId](t: T) {
def uniqueId = UniversalId[T].uuid(t)
}
}
}
6. Type Class in action
Using the above Type Class is thereafter very straightforward. You basically need to import the relevant statements, such as, import UniversalId.ops._, and import UniversalId.instances._, in order for the Donut and Cupcake types to be augmented with a uniqueId method.
import UniversalId.ops._ import UniversalId.instances._
val plainDonut = Donut("PlainDonut",1.50)
println(plainDonut.uniqueId)
val cupcake = Cupcake("VanillaCupcake")
println(cupcake.uniqueId)
You should see the following output when you run your Scala application in IntelliJ:
Plain Donut - 1429730572
Vanilla Cupcake - 414501265
Summary
In this tutorial, we went over the following:
- Define some custom types to model your particular domain
- Use a trait as the contract for a given features
- Create the Companion Object and apply() method for the trait
- Create implicit instances for your relevant types
- Use an implicit class to wire the uuid method from Step 2
- Type Class in action
Tip
- What if you want to augment another custom type that is not part of the UniversalId.instances closure? As a matter of fact, that is a precisely why you would rather consider a general abstraction using a Type Class. All you have to do is add the relevant implicit value for the particular type in scope. For illustrative purposes, we’ll augment all String types with the uniqueId method. It would, of course, be best to add the implicit val stringId under the object instances closure. Otherwise, you can simply place the implicit val stringId in scope as shown below.
implicit val stringId: UniversalId[String] = new UniversalId[String] {
new UniversalId[String] {
override def uuid(s: String) = s"$s - ${s.hashCode}"
}
}
val someString = "AwesomeDonut"
println(someString.uniqueId)
Source Code
The source code is available on the allaboutscala GitHub repository.
What's Next
In the next tutorial, I will show you how to use traits with mixin.