Typeclasses in Scala 3
by Noel Markham
- •
- May 05, 2021
- •
- scala• scala3• functional programming• functional
- |
- 12 minutes to read.

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.