Type Class

dexter2305

Naveen

Posted on April 3, 2024

Type Class

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Let's make the call site polymorphic on the type.

def show[A](a: A)(implicit ii: Show[A]): String  = ii.show(a)
Enter fullscreen mode Exit fullscreen mode

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 = ???
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)}"
  }
Enter fullscreen mode Exit fullscreen mode

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)}")
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

đź’– đź’Ş đź™… đźš©
dexter2305
Naveen

Posted on April 3, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related