Typeclasses and why they matter

Yurii Gorbylov
tranzzo
Published in
5 min readJun 22, 2021

In this article we are going to define what the typeclasses approach is, check its advantages and take a look at the most common cases.

Typeclass is a powerful tool used in functional programming to enable parametric and ad-hoc polymorphism — an elegant alternative to well-known and widely used in OOP subtyping polymorphism.

Typeclasses define a set of functions that can have different implementations depending on the type of data.

Typeclasses vs Subtypes

To demonstrate the advantages of typeclasses over subtyping, let’s take a look at a few functions folding a list with different types:

Since we don’t want to write a separate function for each type, we need to find a proper abstraction that folding a list with all types.

All of the functions above follow the same pattern:

  • an initial value (identity)
  • combining function

A data type with these two properties together form an algebraic structure known as a Monoid.

Let’s try to refactor these functions into a single polymorphic function operating on each type having a monoid structure.

In OOP languages, we often rely on subtyping to implement polymorphic functions. This looks like the code below:

The thing is everytime we try to model the monoid structure using subtyping polymorphism we face the following issues:

  • The identity element can not be modeled as a member of the Monoid trait. If list is empty, we have no values to work with and therefore can’t get the identity value.
  • Base types such as String or external library types cannot extend a new trait, it follows subtyping works only on our own defined classes.
  • This doesn’t enable multiple behaviors for same types as there is just one trait implementation for each type. This applies for the case we need to combine Boolean using || or &&.
  • Pair (and other type constructors, e.g. Option, Try, etc) which are subtyped with Monoidalso forces all instances of Pair to have a Monoid instance. In other words, if we are interested to combine only instances of Pair[String, String], we are still forced to subtype Bitcoin with Monoid to be able to use Pair[Bitcoin, Bitcoin]
  • If a data type implements several behaviors or if we work with a type constructor (e.g. Pair[A, B]), class signature becomes messy:
case class Pair[A <: Monoid[A] with Show[A], B <: Monoid[B] with Show[B]](
first: A,
second: B
) extends Monoid[Pair[A, B]] with Show[Pair[A, B]] {
override def combine(other: Pair[A, B]): Pair[A, B] =
Pair(
this.first combine other.first,
this.second combine other.second
)
override def show: String =
s"Pair(first=${first.show}, second=${second.show})"
}

instead of

case class Pair[A, B](first: A, second: B)

Thus, it appears that subtyping is not a perfect solution to our problems.

Let’s check the same case using typeclasses:

As we can see, the typeclasses approach solves all the issues we have faced using subtyping polymorphism above. Now we:

  • Have a static property identity at the type level
  • Are able to add a behavior to primitive or external types
  • Can have multiple Monoid implementations for a single type
  • Have a clean type signature
  • Are not forced to have a Monoid instance for every type of type constructor

It takes us more effort to set up the typeclasses solution, but at the end it offers us more extensibility.

Typeclasses composition

The power of type classes lies in the compiler’s ability to combine implicit definitions when searching for candidate instances. This is sometimes known as typeclass composition.

Typeclass composition works automatically through Scala’s implicit mechanism. Note that a Monoid[Pair[A, B]] is derived fromMonoid[A] and Monoid[B].

As another motivational example, consider defining a Monoid for anOption.

This method constructs a Monoid for theOption[A] by relying on an implicit parameter to fill in the A-specific functionality.

In this way, implicit resolution becomes a search through the space of possible combinations of implicit definitions, to find a combination that creates a typeclass instance of the correct overall type.

Implicit Conversions

When we create a typeclass instance constructor using an implicit def, be sure to mark the parameters to the method as implicit parameters. Without this keyword, the compiler won’t be able to fill in the parameters during implicit resolution.

implicit methods with non-implicit parameters form a different Scala pattern called an implicit conversion. Fortunately, the compiler will warn us when we do this. You have to manually enable implicit conversions by importing scala.language.implicitConversions in your file.

Typeclasses in Cats

Cats is written using a modular structure that allows us to choose which typeclasses, instances, and interface methods we want to use.

Let’s take a first look using cats.Monoid as an example.

The typeclasses in Cats are defined in the cats package. We can import Monoid directly from this package:

import cats.Monoid

The companion object of every Cats typeclass has an apply method locating an instance for any type we specify:

val monoidString = Monoid.apply[String]

The cats.instances package provides default instances for a wide variety of types. We can import these as shown on the list below. Each import provides instances of all Cats’ typeclasses for a specific parameter type:

  • cats.instances.int provides instances for Int
  • cats.instances.string provides instances for String
  • cats.instances.list provides instances for List
  • cats.instances.option provides instances for Option
  • cats.instances.all provides all instances that are shipped out of the box with Cats

We can make Monoid easier to use by importing the interface syntax from cats.syntax.monoid. This adds an extension method called combine to any type for which we have an instance of Monoid in scope:

Typeclass Boilerplate

Typeclasses rock. However, their encoding in Scala requires a lot of boilerplate, which doesn’t rock. Also, there is an inconsistency between projects, where typeclasses are encoded differently.
To solve these problems we can use Scala library simulacrum by adding the dependency to build.sbt:

libraryDependencies += "org.typelevel" %% "simulacrum" % "x.x.x"

The simulacrum is aimed to introduce first-class support for typeclasses in Scala. For example:

Something similar to the following is generated at compile time:

The Ops trait contains extension methods for a value of type A for which there's a Monoid[A] instance available.

The ToMonoidOps trait can be mixed into a class or object in order to get access to the extension methods.

The ops object provides an implicit conversion that can be directly imported in order to use the extension methods.

--

--