Typeclasses in Scala 3

Typeclasses in Scala 3

Last week, we looked at a new keyword: enum. Today, we’re going to look at one of the fundamental building-blocks of typed functional programming: typeclasses.

If you haven’t come across typeclasses before, they expose a powerful feature not seen in typed imperative languages such as Java and C#: ad-hoc polymorphism. Typeclasses allow you to effectively extend or implement functionality to a type created by a different party. A nice example is comparing values to find the largest; using an inheritance approach, your type would be required to implement an appropriate ordering or comparison operator. Something like this would be written in Java:

public static <T extends Comparable<T>> T largestValue(List<T> elements) { ... }

Your type T must extend Comparable in order to use it. If somebody else wrote that class, it’s up to them to fix it.

Typeclasses remove this constraint, and allow you to provide the comparison mechanism after the fact. The compiler will require the comparison mechanism is available when you need it, but it is separate from the underlying type.

Typeclasses in Scala 3

Let’s look at how we could implement hashing of an object, rather than using the hashCode method extended from java.lang.Object. Why on earth would we ever want to do this? Well, in the spirit of ad-hoc polymorphism, we may wish to provide our own implementation of hashing for our own needs: perhaps the class author wrote a poorly performing - or wrong - implementation. Perhaps the class isn’t maintained anymore. There are plenty of reasons.

trait Hashed[A]:
  def hashed(a: A): Int

We can provide implementations using the new given keyword:

given Hashed[String] with
  def hashed(s: String): Int = s.length

Now, for instance, if you were to implement your own object storage library, you could require that, in order for types to be stored, they require a Hashed instance:

def storeObject[A](obj: A)(using hash: Hashed[A]): F[Unit] = ...

(We can imagine that F[_] is some kind of effectful system)

The using keyword requires that the compiler can find a given instance of Hashed[A]. Simply from reading the function definition, it is clear that whatever it does, it will require Hashed[A] to be implemented.

As you progress through your functional programming journey, dependence on typeclasses can communicate a lot to you about what a particular function does. For example, when looking at the Writer type in Cats, the tell function requires a Semigroup. If you know nothing about Writer, but know that a Semigroup involves combining two values of the same type, it immediately becomes intuitive that tell is combining or appending values in some way. Combine that with the idea that the Writer type is for . . . writing . . . you start to get an idea of how these pieces fit together: tell is doing the writing part, and the Semigroup instance indicates how that writing takes place.

Back to storeObject, we are not constrained to types we already know about:

case class Score(total: Int)

given Hashed[Score] with
  def hashed(s: Score): Int = s.total

The storeObject function now works with our brand new type. Similarly, if someone else has a specific requirement for how hashing works for them, they can provide their own implementation:

given Hashed[String] with
  def hashed(s: String): Int = s.hashCode // use the original hashCode

As you may expect, you can only have one instance in scope at compile time. The compiler will help you if it finds more than one instance:

[error]    |given_Hashed_String$ is already defined as object given_Hashed_String
[error] 35 |  def hashed(s: String): Int = s.hashCode

More typeclasses: Boolean Logic

We can implement a typeclass to implement boolean logic. There are plenty of different instances in the world that follow the rules of boolean logic, so it makes sense that we should have a way to define this behavior once and for all:

trait Bool[B]:
  def and(x: B, y: B): B
  def or(x: B, y: B): B

For any type for which and and or operations are appropriate, we can create an instance of this typeclass. There is a very obvious first implementation:

given Bool[Boolean] with
  def and(x: Boolean, y: Boolean): Boolean = x && y
  def or(x: Boolean, y: Boolean): Boolean = x || y

We should note here that, for a fully operational boolean logic typeclass, this is not complete. There are other boolean operations such as xor, nand, as well as simple negation. Some of these operations can be implemented in terms of others, and so an implementation available to all could be included on the base trait.

Now, we can summon this instance in the REPL:

scala> summon[Bool[Boolean]]
val res0: given_Bool_Boolean.type = Main$package$given_Bool_Boolean$@585ddaab

scala> res0.and(true, true)                                                                                                                           
val res1: Boolean = true

scala> res0.or(false, true)                                                                                                                           
val res2: Boolean = true

Aside: Extensions

A new feature in Scala 3 is the extension keyword. This allows you to provide new functions as operators on already-existing types, giving the appearance to users that the function was implemented as part of the type in the first place. This was achieved in Scala 2 using implicit classes, and has heavy use in libraries such as Cats.

We can update our Bool typeclass to add a couple of extensions:

trait Bool[B]:
  def and(x: B, y: B): B
  def or(x: B, y: B): B

  extension(x: B)
    def booleanAnd(y: B): B = and(x, y)
    def booleanOr(y: B): B = or(x, y)

As long as our given instance is in scope, these extensions will work. Dropping straight into a console from SBT:

sbt> console

scala> true.booleanAnd(false)
val res0: Boolean = false

scala> false booleanOr false // infix notation
val res1: Boolean = false

“Using” typeclasses

Typeclasses are available to functions with the using keyword:

scala> def merge[A](list: List[A])(using b: Bool[A]): A = list.reduce(b.and)
def merge[A](list: List[A])(using b: Bool[A]): A

scala> merge(List(true, true, true))
val res2: Boolean = true

scala> merge(List(true, true, false))                                                                                                          
val res3: Boolean = false

If you do not need the name of the using context parameter (e.g., if your function calls a function that relies on that using parameter), you can pass it anonymously, without giving it a variable name:

scala> 255.toBinaryString
val res3: String = 11111111

scala> 300.toBinaryString
val res4: String = 100101100

scala> def applyMask[A](mask: A, value: A)(using Bool[A]): A = merge(List(mask, value))                                                               
def applyMask[A](mask: A, value: A)(using x$3: Bool[A]): A

scala> applyMask(255, 300)
val res2: Int = 44

scala> 44.toBinaryString
val res5: String = 101100

Here, applyMask calls our previous merge function. So while we need the context parameter available to applyMask, all that we do is pass it straight on to merge.

More implementations

We can implement our Bool typeclass for performing bitwise operations on integers:

given Bool[Int] with
  def and(x: Int, y: Int): Int = x & y
  def or(x: Int, y: Int): Int = x | y

And this works as you would expect:

scala> 10.toBinaryString                                                                                                                              
val res0: String = 1010

scala> 9.toBinaryString                                                                                                                              
val res1: String = 1001

scala> 10.booleanOr(9)                                                                                                                                
val res2: Int = 11

scala> 11.toBinaryString                                                                                                                              
val res3: String = 1011

scala> 10.booleanAnd(9)                                                                                                                               
val res4: Int = 8

scala> 8.toBinaryString                                                                                                                               
val res5: String = 1000

Our original merge function still works:

scala> merge(List(9, 10))
val res6: Int = 8

Right here is the beauty of typeclasses over inheritance: merge was written before we decided to have a Bool typeclass for Int, yet the function still works. Better still, an author of a brand new type that obeys the laws for boolean logic gets to use this function with no extra work necessary.

What other implementations you can think of for this typeclass? Is it possible to implement this for, say, a set? How about functions?

Final Words

I hope this has provided a useful introduction to typeclasses for those not familiar. And, for those who have experience with Scala 2, I hope this has demonstrated how Scala has evolved to bring typeclasses right to the front of the language.

In a nutshell, if you want to define your own typeclass, use a trait with a type parameter to define the operations. Instances of typeclasses are not implemented by extending the trait, but by using the given keyword. You can make any functions appear to naturally fit with the original type implementation using the extension keyword: it will look like the function was part of the underlying type all along. Should you wish to get a hold of a given typeclass instance on the REPL, the summon operation will give you a handle on that. That same handle is available to functions with the using keyword: the compiler will ensure that instance is available to the function at compile time.

Next time, we are going to continue this look at typeclasses with an approach to automatically derive some typeclass instances. Keep an eye on our blog and Twitter for that and more Scala 3 content in the near future.

Ensure the success of your project

47 Degrees can work with you to help manage the risks of technology evolution, develop a team of top-tier engaged developers, improve productivity, lower maintenance cost, increase hardware utilization, and improve product quality; all while using the best technologies.