Typeclasses and why they matter
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 withMonoid
also forces all instances ofPair
to have aMonoid
instance. In other words, if we are interested to combine only instances ofPair[String, String]
, we are still forced to subtypeBitcoin
withMonoid
to be able to usePair[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 asimplicit
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 importingscala.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 forInt
cats.instances.string
provides instances forString
cats.instances.list
provides instances forList
cats.instances.option
provides instances forOption
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.
Worksheets
- The problem: abstact fold function
- Subtyping aproach
- Typeclasses aproach
- Typeclasses compositions
- Typeclasses boilerplate
References
https://www.scalawithcats.com/dist/scala-with-cats.html
https://typelevel.org/cats/typeclasses.html
https://medium.com/se-notes-by-alexey-novakov/of-scala-type-classes-6647c48e39d9
https://medium.com/decisionbrain/an-introduction-to-type-classes-in-scala-790069926d07