Naveen
Posted on April 3, 2024
Type class is an abstract, parameterized type used to extend the behavior of a closed type without sub-typing it.
Overview
Suppose, we want to represent an instance of a particular type in its string format. The built-in toString
is not always useful and is a typical example to understand type class. There are 2 steps to creating a type class
Implement a type class
Implement type class instances of concrete types.
Once the instance is created, there are different approaches to using the instance with slight variations.
Implement a type class
Let's define a type class to define the method to return a string of the type instance.
trait Show[T] {
def show(t: T): String
}
A type class is a trait. It is parameterized on a type and defines a behavior. The behavior in Show is the method show(t: T). This becomes the template to apply to those types that require.
Implement concrete type class
Let us assume, Domain type must be Showable. We implement Show[Domain] and create a type class instance.
case class Department(name: String)
class ShowDepartment extends Show[Department] {
override def show(d: Department) = d.name
}
implicit val showDepartmentInstance = new ShowDepartment
Type class instance showDomain
need not be implicit to be a type class instance! This is covered in the usage of the instance. That's it. We have implemented a concrete type class and created a type class instance.
Usage
Different use cases and convenience required in usage lead to multiple approaches that the type class and its implementation evolve with additional methods. There are two types of using the implementation of concrete type class. They are classified as instance type and syntax type.
Instance type
We have an instance of type class created and it could be explicitly used by direct reference. This makes the call site tightly coupled to the type which is probably never a requirement.
def show(a: Domain): String = showDepartmentInstance.show(a)
Let's make the call site polymorphic on the type.
def show[A](a: A)(implicit ii: Show[A]): String = ii.show(a)
Now the call site is polymorphic on type. The type class instance to be used depends on the type. Hence, the instance is marked implicit. This means a type of Show[A]
must be present in the implicit scope. This is the reason the type class instance showDomain
is marked implicit for the call site to work with Department
.
Sugared usage
Call site still uses an implicit reference to type class instance and can it be eliminated? Being so, the call site becomes concise. The conciseness is in the idea that there is no reference to type class instance in the snippet. Call site is completely expressed as the name of type class and the behavior required.
def show[A: Show](a: A): String = ???
The semantic show[A: Show]
is referred to as the context-bound type. So, it is read as the call site method show
is polymorphic on A provided the Show is bound to A.
In a context-bound call site of a type class instance, there is no direct reference to a type class instance. So, we use implicitly[_]
.
def show[A: Show](a: A): String = implicitly[Show[A]].show
The design of the type class has forced the call site to implicitly resolve the type class instance whether it is sugared or the de-sugared form. This can be improved by a companion for the type class which summons the implicit instance of the type class.
// companion for the type class `Show`
object Show {
def apply[T](implicit instance: Show[T]) = instance
}
// call site
def show[A: Show](a: A) = Show[A].show(a)
Companion object only summons an implicit instance. It does not create one. The complete implementation looks this way
// type class
trait Show[A] {
def show(a: A): String
}
object Show {
// summons an implicit instance
def apply[A](a: A)(implicit i: Show[A]) = i
}
case class Department(name: String)
object Department {
// creation of implicit instance
implicit val showDepartmentInstance = new Show[Department] {
override def show(d: Department) = d.name
}
}
// call site #1 with implicit parameters defined
def show_v1[A](a: A)(implicit ii: Show[A]) = ii.show(a)
// call site #2 with context bound implicit instance
def show_v2[A: Show](a: A) = Show[A].show(a)
// test
val d = Department("Engineering")
show_v1(d) // String: Engineering
show_v2(d) // String: Engineering
The problem statement could further be extended but the solution pattern is already simplified. In the current domain model, let's suppose there is a Student
belonging Department
.
case class Student(name: String, department: Department)
object Student {
// create implicit instance
implicit val showStudentInstance = new Show[Student] {
override def show(s: Student) =
s"${s.name}, ${Show[Department].show(s.department)}"
}
}
// test
val s = Student("Martin", Domain("Engineering"))
show_v1(s) // String: "Martin, Engineering"
show_v2(s) // String: "Martin, Engineering"
Student
has an instance of Department
. So, the show()
of the Student requires the show()
of the Department. Since there is one in the implicit scope already, it only needs to be summoned. Hence, Show[Department]
.
However, we notice a redundancy in the way type class instances are getting created. The core difference lies only in the function by which a given instance of Department
or Student
would be transformed to String. The rest of it is all boilerplate.
// from Department
implicit val showDepartment = new Show[Department] {
override def show(d: Department) = d.name
}
// from Student
implicit val showStudentInstance = new Show[Student] {
override def show(s: Student) =
s"${s.name}, ${Show[Department].show(s.department)}"
}
This can be solved by enhancing the companion object of Show
with a helper method to create instances given a required function. It is just a helper function to create instances but the instances get created when this helper function is called.
trait Show[A] {
def show(a: A): String
}
object Show {
// summons the instance
def apply[A](implicit inst: Show[A]) = inst
// helper: to create type class instance.
def instance[A](f: A => String) = new Show[A] {
override def show(a: A): String = f(a)
}
}
case class Department(name: String)
object Department {
// instance creation
implicit val showDepartmentInstance = Show.instance[Department](d => d.name)
}
case class Student(name: String, department: Department)
object Department {
implicit val showStudentInstance = Show.instance[Student](s => s"${s.name]} from ${Show[Department].show(s.department)}")
}
All the boilerplate code to create an instance is now replaced by a function.
It appears to be a lengthy topic about type class with instance type of usage but the definition of type class and its concrete implementation define the type class behavior. The rest of it is the evolution of the companion object Show
from not existing in the bare-bone implementation of type class to a flavor with helpers to create and summon the type class instance.
Syntax type
With the syntax type, we can improve the type class usage with the possibility of calling the show()
on the given object as if it were defined on the object itself. This is called the extension method. This is achieved by having an implicit class wrapping over the instance of type T.
implicit class ShowOps[A](a: A) {
def show(implicit instance: Show[A]): String = instance.show(a)
}
case class Department(name: String)
val enggDep = Department("Engineering")
enggDep.show // String: "Engineering"
There is no show()
on Department
yet, with the syntactic usage of a type class, the method appears to be on the Deparment
. The method show()
could have been given any other name as well. It is an arbitrary method name but show()
makes sense in this case. This is re-written as (new ShowOps[Department](enggDep)).show(showDomainInstance)
by the compiler. In this implementation of ShowOps
, it is only the method show
constraint by the type. An alternate implementation is to enforce the constraint at the class level.
implicit class ShowOps[A: Show](a: A) {
def show(): String = Show[A].show(a)
}
case class Department(name: String)
val enggDep = Department("Engineering")
enggDep.show
I prefer enforcing the constraint at the lowest unit of work which, in this case, is the methodshow
leaving the class generic.
Where to place Ops implementation?
Class ShowOps
is generic and can be placed in the companion of Show
.
trait Show[A]{
def show(a: A): String
}
object Show {
// instantiator
def instance[A](f: A => String) = new Show[A] {
def show(a: A): String = f(a)
}
// summoner
def apply[A](implicit instance: Show[A]): Show[A] = instance
object ops {
implicit class ShowOps[A](a: A) {
def show(implicit instance: Show[A]) = instance.show(a)
}
}
}
import Show._
import Show.ops._
case Department(name: String)
val d = Department("Finance")
d.show // String: Finance
Type class is an interesting concept and I have been reading about it from different sources. So, I decided to pen down my learning from different sources like scala-lang.org, scalac.io, with examples that I could relate to better.
Posted on April 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.