Journey of Implicits in Scala — part 3
Type Classes in Scala
This is the third part of a 5 part series to understand the concept of implicits in Scala . The road taken is as follows
- Introduction to implicit methods or variable definitions and method parameter lists.
- Type Annotations in Scala
- Type classes in Scala
- Implicit hell and how painfull it really is?
- Scala 3’s approach to Type Classes.
In this part we will see how to encode type classes and how type classes can lead to ad-hoc polymorphism.
Let’s start directly with an example. We look at how we can convert a case class
to an HTTP Response.
case class User(id: Long, name: String)object ResponseUtils {
def createdResponse(user: User): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/${user.id}")
}ResponseUtils.createdResponse(User(1l,"John Doe"))
We define a case class
User
. We would like to create a HTTP
201 created
response from this. We use the ResponseUtils.createdResponse
method , which uses the id
from User
to specify the user resource location.
Soon we realise that we need a similar method for other entities in our domain. So, we add methods similar to the one already present for User
case class Employee(id: Long, name: String)object ResponseUtils {
def createdResponse(user: User): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/${user.id}")
def createdResponse(employee: Employee): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/${employee.id}")
}ResponseUtils.createdResponse(Employee(1l,"Tom Harry"))
For another case class in our domain Employee
, we added a new overloaded
method createdResponse
to create an HTTP
response for Employee
. This is a class polymorphism
with method overloading where we write two or more methods in the same class by using same method name, but the passing parameters is different.
We have a working solution but something is still amiss. Both the overloaded method’s have almost the same definitions. Both these classes have something in common which is all we need in the createdResponse
method. Both of them have an id
. Let’s use that common feature of both classes to create a super class/trait
and then use the trait
in our createdResponse
method.
trait Id {
def id: Long
}case class User(id: Long, name: String) extends Idcase class Employee(id: Long, name: String) extends Idobject ResponseUtils {
def createdResponse(id: Id): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/${id.id}")
}ResponseUtils.createdResponse(User(1l,"John Doe"))
ResponseUtils.createdResponse(Employee(1l,"Tom Harry"))
Each of our entity class — User
, Employee
now extend an Id
trait. The ResponseUtils.createdResponse
can now take a type of Id
to create the HttpResponse
. Above code can also be written as
trait Id {
def id: Long
def createdResponse(id: Id): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/$id")
}case class User(id: Long, name: String) extends Idcase class Employee(id: Long, name: String) extends IdUser(1l,"John Doe").createdResponse
Employee(1l,"Tom Harry").createdResponse
With this approach we have moved the desired method inside the parent class and we are directly calling the createdResponse
method on the User
and Employee
instance. This has a nice feeling to it. We can also override
the createdResponse
method in the sub-classes if required. This is another classic example of polymorphism
using method overriding
.
However with this approach we loose a lot of flexibility. Our entity classes have to extend a trait. What if we come across an entity that cannot be changed. If its a class that is outside our control and the source code cannot be changed?
Let’s look at the problem from another angle. We need to use the id
property of the the entity classes. If not use them directly , we need to provide some proof that an id
will be available. Let’s encode this proof using a type class pattern. If you remember the discussion in series 2: Type
is a set of things which share same characteristics. Members of a type
belong to that set. class
or interfaces
or traits
are ways to create members of that set. The characteristic here is that they all have an id
.
trait HasId[T] {
def getId(t: T): Long
}
If we manage to get your hands on an instance of HasId[T]
for a given T
, you know how to retrieve its id
. We can use User
, Employee
as type parameters
to create new types.
object UserObject extends HasId[User] {
override def getId(user: User): Long = user.id
}object EmployeeObject extends HasId[Employee] {
override def getId(emp:Employee): Long = emp.id
}object ResponseUtils {
def response[T](t: T, idProof: HasId[T]): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/${idProof.getId(t)}")
}
And now we can use it like this
ResponseUtils.response(User(1l,"John"), UserObject)
ResponseUtils.response(Employee(1l,"John"), EmployeeObject)
When we look at the definition of ResponseUtils.response
, it is exactly as polymorphic
as we want. It exposes an operation which takes any entity T
, as long as there is an implementation of HasId
for that entity.
The HasId[T]
, which up until now has been referred as the “proof” is the Type class.
It is the trait that abstracts the constraint that is used to deliminate which types would be allowed to be operated on polymorphically.
The UserObject
and EmployeeObject
above are referred to as the Type class instance. User
and Employee
above are the objects that need to be operated on in a uniform way. And they are linked to the type class instance by being the type parameter in the definitions of the type class instances.
And last but not the least, we have the ResponseUtils.response
definition itself that provides an interface for us to use the code in a polymorphic fashion which is
object ResponseUtils {
def response[T](t: T, idProof: HasId[T]): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/${idProof.getId(t)}")
}
So, in summary, the design for encoding the type class pattern consist of the following components:
- A Type class (a trait with type parameters that describes the evidence)
- Type class instances (implementations of the trait for each object required to provide an evidence)
- The interface via which the polymorphic operation is applied
Above three are the essential part of the type class pattern. There is one more crucial thing that makes this design of the machinery complete in Scala.
That crucial thing is the implicit
feature to make supplying the type class instance a lot more seamless. Basically, we will be making the Scala compiler work for us.
Making use of Implicits
Looking at the code snippet below:
object ResponseUtils {
def response[T](t: T, idProof: HasId[T]): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/${idProof.getId(t)}")
}
In the ResponseUtils.response
method, one needs to explicitly pass in the t (Entity
) together with its type class instance (HasId [T]
).
But would it not be cool to be able to only specify the entity, and let the
HasId
be automatically provided for us by the Scala compiler? And if the Scala compiler cannot find the required proof of id (i.e. the required type class instance), then the compilation should fail?
This is possible by making use of implicits. To do this, we slightly modify the definition of ResponseUtils.response
. This looks like this:
object ResponseUtils {
def response[T](t: T)(implicit idProof: HasId[T]): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/${idProof.getId(t)}")
}
We break the parameter definitions into two, and we tag the last one with the implicit keyword, which would make it a candidate to be automatically provided for us by the Scala compiler in cases where it is omitted.
The next thing we need to do is to mark our concrete evidence, our type class instances
with the implicit
keyword. This would make them viable options to be included in the parameter list by the Scala compiler.
implicit object UserObject extends HasId[User] {
override def getId(user: User): Long = user.id
}implicit object EmployeeObject extends HasId[Employee] {
override def getId(emp:Employee): Long = emp.id
}
Let’s take a look at these changes together with the rest of the code snippet
case class User(id: Long, name: String)
case class Employee(id: Long, name: String)trait HasId[T] {
def getId(t: T): Long
}implicit object UserObject extends HasId[User] {
override def getId(user: User): Long = user.id
}implicit object EmployeeObject extends HasId[Employee] {
override def getId(emp:Employee): Long = emp.id
}object ResponseUtils {
def response[T](t: T)(implicit idProof: HasId[T]): HTTPResponse =
HTTPResponse(201,s"http://localhost:8080/${idProof.getId(t)}")
}ResponseUtils.response(User(1l,"John"))
ResponseUtils.response(Employee(1l,"John"))
In the above example, the missing parameter list is automatically provided because they are in the local scope. As usual, the Scala compiler would scan the implicit scope for values that are suitable.
In cases where we pass in an Entity that does not possess an id proof, aka, a entity without an instance of HasId[T]
in implicit scope, then the compilation would fail. For example something like this:
ResponseUtils.createdResponse(SomeOtherEntity(1l,"John Doe"))
// fails at compile time
The last thing we are going to touch on is how Context bound and implicitly function, two other language features of Scala that are usually applied when putting type class pattern together.
Using Context Bound Annotation and the implicitly function
If you take a look at the definition of the response
method, and find a way to make it less verbose. That is:
...
def response[T](t: T)(implicit idProof: HasId[T]): HTTPResponse
...
A context bound annotation like [T: Bound]
which allows us to specify that for any type T
there must be an implicit value of Bound[T]
.
Which in essence, is what we are doing. We want that for any entity T
there must be an instance of HasId[T]
.
You can go check out the 2nd post of this series for an overview of context bound annotation.
Modifying the code, to include usage of context bound would leave us at:
object ResponseUtils {
def response[T: HasId](entity: T): HTTPResponse = ???
}
As we can see, since we have removed the (implicit idProof: HasId[T]
) portion of the method definition, we would no longer have access to getId
in the method body.
But this is where the implicitly
function comes to our rescue. The implicitly function allows us to reach into the implicit scope and get hold of an implicit value of interest.
In this case, we need to grab a hold of an implicit value of type HasId[T]
, which was what the idProof
variable was. but since it is now gone, we can replace it with: implicitly[HasId[T]].getId(t)
Taking together the updated code snippet would look like:
case class User(id: Long, name: String)
case class Employee(id: Long, name: String)trait HasId[T] {
def getId(t: T): Long
}implicit object UserObject extends HasId[User] {
override def getId(user: User): Long = user.id
}implicit object EmployeeObject extends HasId[Employee] {
override def getId(emp:Employee): Long = emp.id
}object ResponseUtils {
def response[T: HasId](entity: T): HTTPResponse = {
val id = implicitly[HasId[T]].getId(t)
HTTPResponse(201,s"http://localhost:8080/$id")
}
}ResponseUtils.response(User(1l,"John"))
ResponseUtils.response(Employee(1l,"John"))
And with that, we are able to make use of context bound annotation ( together with the help of the implicitly function) to make the type class encoding a little less verbose.
In the next post, we will see some issues that might be faced by developers when using libraries that are based on this type class pattern.
Further readings