Type class is a useful concept to let a function process different types of data in a controlled approach. Scala provides implicity parameter, context bound and the implicitly function to support the use of type class. This is a summary of the purpose and usage of type class. The later parts is primarily based on the the Youtube video Don’t fear the Implicits: Everything You need to Know About Typeclasses and Polymorphism in Scala

1 Introduction

There are three types of polymorphism: subtyping, parametric and ad-hoc.

  • subtyping polymorphism: the super-type sub-type relationships in object oriented context. It is not recommended for Scala because the is-a relationship is too restrict and hard to maintain.
  • parametric ploymorphism: generic class and generic trait are type constructors that create parameterized types.
  • ad-hoc polymorphism: type-specific methods that invoke different implementation for different types. Method overloading/overriding are ad-hoc polymorphism. Scala also provides implicit parameters and other construct to suport ad-hoc polymorphism, similar to the type class in Haskell.

The Ad-hoc polymorphism and type classes blog gives a clear defintion of type class:

Type class is a concept of having a type-dependent interface and implicit implementations of that interface, with separate implementation for each supported type.

In Scala, methods in a generic trait define the interface of a type class. Define objects of the parameterized class as an implicit parameter for different types.

The essence of type class is to let a method use a parameter whose type is from a set of types that don’t have a common super-type, while control the set of types used as the parameter types. This Type-class implicits blog explians the motivation. Scala has constructs (implicit parameter, context bound and imlicitly method) to make it easy. The context bound [A: B] specifies the parameter type A should come with an implicit value of type B[A] in scope.

2 Implementation

The implementation of a case class invloves the following steps.

First, the operations are defined in a generic trait, the type class.

1
2
3
trait TypeClassOps[A]{
  def op1(a: A): Unit  // the result can be any type
}

Second, in the companion object of the type class (or other objects), create an instance for each type A

1
2
3
4
5
object TypeClassOps {
  implicit myTypeInstance = new TypeClassOps[MyType] {
    override def op1(a: MyType) = ???
  }
}

Third, in any place, usually in the companion object of the type class, define the functions that use the data and its operations.

1
2
3
4
5
6
7
object TypeClassOps {
  // ...countinued
  def useOps[A: TypeClassOps](a: A) = {
    val val = implicitly[TypeClassOps[A]].op(a)
    // use the type class functions
  }
}

The context bound [A: B] will add an implicit paramter to the method that can be accessed using the implicitly[B[A]] syntax.

Therefore, it is easy to make it to work with any type, as long as an implicit instance of TypeClassOps[A] is in scope, the userOps works. There is no need to change that type.

The optional fourth step is to define an implicit class for each class that supports the typeclass methods using the constructs. This enriches a class with the additional trait methods. This is an addon, not an essential goal of typeclass.

Summary

  • Using implicit parameters we can use a common interface for a T if T implements the interface or the implementation is in its implicit scope.
  • Using implicit instance we can have multiple implementations for a single type and bring a specific instance into scope.

3 Implicit Resolution

The order or implicit resolution, from high to low, is:

  • Explicit
  • Local
  • Imported
  • Inherited
  • Package object
  • Implicit scopes
    • companion object of type class
    • companion object of A
    • companion objects or super types

The compiler reports an error if no implicit value found or more than ones in the same order level.

4 Usabilities

The following patterns easy the use of type class

4.1 Operators

The use of def op1(a: A) can be simplified by defined an implicit class:

1
2
3
implicit class TypeClassOps[A](a: A)(implicit tca: TypeClassOps[A]) {
  def op1() = tca.op1(a)
}

The above implicit class allows a.op1() call from an instance of A, adding a new method op1 to the class.

4.2 Error Message

To give better error message, add a notation to the type class. An example is

1
2
3
@implicitNotFound("No implicit instance of typeclass TypeClassOps for type ${A}.")
@implicitAmbiguous("Ambiguous implicit of instances typeclass TypeClassOps for type ${A}.")
trait TypeClassOps[A] {...}`.

4.3 Shortcut for implicitly

If we just want to call the methods defined on the typeclass, in type class’s companion object, define an apply[A](implicit a: TypeClassOps[A]) = a to short the implicitly[TypeClassOps[A]] as TypeClassOps[A] when context bound is used. The call will be TypeClassOps[FooClass].op(fooInstance)

4.4 Type Class Contructor

It is a common practice to define factory methods to create the implicit instances of the type class. The factory implementation is type-specific but usually can simplifies the creation of the implict instances. For example, you can create an implicit Ordering[A] instance using Ordering.by or Ordering.fromLessThan methods.

4.5 Combinators

Type classes often provide combination or transformation operators. The purpose is to reuse existing instances or derive from those instances.

4.5.1 Simple Field

Following is an exmple:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import io.circe.{Encoder, Json}
import io.circe.Encoder._

case class BookId(value: Int) extends AnyVal
object BookId {
  implicit val BookIdEncoder: Encoder[BookId] = Encoder[Int].contramap(_.value)
}

// in package io.circe
trait Encoder[A] extends Serializable { self =>
  def apply(a: A): Json

  final def contramap[B](f: B => A): Encoder[B] = new Encoder[B] {
    final def apply(a: B) = self(f(a))
  }
}

4.5.2 Generic Type Class Instances

Also called implicit derivation. In this case, an existing implicit instance is used to generate an implicit instance of a generic class such as Option[A], List[A] etc.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package io.circe

object Encoder {
  implicit final def encodeOption[A](implicit e: Encode[A]): Encode[Option[A]] =
    new Encode[Option[A]] {
      final def apply(a: Option[A]): Json = a match {
        case Some(v) => e(v)
        case None => Json.Null
      }
    }
}

4.5.3 Derving Instances

It is possible to derive instances of Foo[A] from instances of Bar[A]. For example, Akka HTTP allows plugging in type class based JSON libraries. It defines a type class ToEntityMarshaller that converts any Scala types to Http request/reponse bodies. The ToResponseMarshaller that coverts to Http response.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// circe plugin
import akka.http.scaladsl.marshalling.{Marshaller, ToEntityMarshaller}
import akka.http.scaladsl.model.mediaTypes.`application/json`
import io.circe.{Encoder, Json, Printer}

trait CirceSupport {
  implicit def circeToEntityMarshaller[A](
    implicit encoder: Encoder[A], printer: Json => String = Printer.noSpaces.pretty): ToEntityMarshaller[A] =
    Marshaller.StringMarshaller.wrap(`application/json`)(printer).compose(encoder.apply)
}

// domain model
import io.circe.Encoder
import io.circe.Endoeer._
import io.circe.generic.semiauto._

case class Book(id: BookId, title: String, author: String)
object Book {
  implicit val bookEncoder: Encoder[Book] = deriveEncoder[Book]
}

// in app
import akka.http.scaladsl.server.Directives._
import my.CirceSupport

trait Routes extends CirceSupport {
  val bookRepo = new BookRepo

  val route = path("books" / IntNumber) { bookId =>
    get {
      complete {
        bookRepo.find(BookId(bookId))
      }
    }
  }
}

The above code creates an ToEntityMarshaller[A] from ToEntityMarshaller[String](the type of Marshaller.StringMarshaller).

4.5.5 Default Instances

Provide default instances if they make sense. To make it easy to opt out default instances, put default instances in companion objects that can be easily replaced by local or inherited instances.

Don’t provide default instances if they don’t make sense. For example, PTypeH[AnyRef] for AnyRef might not be a good idea because it is too general.

4.5.6 Serializable

Try to make case classes serializable because they are often sent over network.

5 Questions About Type Class

Try to answer the following questions:

  • Is retroactive extension important? Case class allows you to work with existing unchangable types.
  • Can you provide good defaults? Only define defaults when they make sense.
  • What is the tradeoff of boilerplate/clarity? Automatically deriving instances could be confusing, especially when several type cases involved.
  • Can you support two usage models, one not relying on implicits? scodec is a good example that provides implicit and non-implicit usages.

The typeclass hierarchy in Cats lib: All things you traverse.

6 Typeclass Laws

7 Smmary

  • The pirmay function of type class is to enable retroactive extension.
  • It is an alternative to subtyping and adpater approach.
  • Typeclass works on types, not values.
  • It is better to provide simple constructors and combinators.
  • Automatic derivation is powerful.
  • Interactions of multiple typeclasses are hard to understand.
  • Typeclass can have laws to allow concurrent computation.