Learn myself some Scala 3, episode 3: type classes

Update: Thanks to feedback from @kai_nyasha and @loic_d I have removed the now unnecessary separate type class syntax stuff.

Type classes are implemented as a three-part pattern in Scala 2: we use a polymorphic trait with one or more abstract methods for the type class and different applications of the implicit keyword to provide the type class syntax and the type class instances:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// The type class is a polymorphic trait
trait SemiGroup[A] {
def combine(a1: A, a2: A): A
}

// The type class syntax is provided by extension methods
// with an implicit parameter for the type class
final implicit class SemiGroupSyntax[A](val lhs: A) extends AnyVal {
final def |+| (rhs: A)(implicit sga: SemiGroup[A]): A =
lhs.combine(rhs)

def combine(rhs: A)(implicit sga: SemiGroup[A]): A =
sga.combine(lhs, rhs)
}

// The type class instance is an implicit value
// implementing the type class
final implicit val intSemiGroup: SemiGroup[Int] =
_ + _

In Scala 3 the approach is simplified: all we need are extension methods which we covered in the previous blog post and the new given instances.

Type Classes

As in Scala 2 type classes are polymorphic traits with one or more abstract methods. Yet instead of “normal” methods we already provide the syntax via extension methods. Then every type, which has a given instance (see below) of the type class in scope, acquires the extension methods.

To define a semigroup type class we use the new experimental brace-less syntax, which might or might not make it into Scala 3:

1
2
3
4
5
6
7
8
9
package lmss

trait SemiGroup[A]:

extension (lhs: A)
def combine(rhs: A): A

final def |+|(rhs: A): A =
lhs.combine(rhs)

Given Instances

To provide type class instances we use given instances, which can be anonymous, implementing the abstract type class methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
package lmss
package instances
package semigroup

given SemiGroup[Int]:
extension (lhs: Int)
override def combine(rhs: Int): Int =
lhs + rhs

given [A] as SemiGroup[Seq[A]]:
extension (lhs: Seq[A])
override def combine(rhs: Seq[A]): Seq[A] =
lhs ++ rhs

Given Imports

Unless the respective given instances are already in scope, e.g. locally or via inheritance, they have to be imported. Other than in Scala 2 we cannot use a standard import clause, but instead we have to use the new given imports:

1
2
3
4
scala> import lmss.instances.semigroup.{ given _ }

scala> 42 |+| 7
val res0: Int = 49

The above imports all given instances from the semigroup package. We can also select specific types to be imported:

1
scala> import lmss.instances.semigroup.{ given lmss.SemiGroup[?] }

Conclusion

Compared to Scala 2 type classes are easier and more straightforward to implement in Scala 3: no more implicit classes for the syntax needed, just the type class as a polymorphic trait with extension methods and type class instances as given instances.