When learning Scala you'll eventually encounter two forms of polymorphism. If you come from a Java or C++ then you'll be familiar with parametric and subtype polymorphism. However, Scala also supports ad-hoc polymorphism via type classes. A type class in Scala 3 is usually formed by defining a trait that will be implemented for the supporting types. A prime example of this is the Show
type class, a type class that's used to turn a type into a String
.
trait Show[A]:
def show(a: A): String
object Show:
//implementation of Show for Int
given Show[Int] with
def show(i: Int) = i.toString
extension [A](a: A)(using s: Show[A]) def show = s.show(a)
Ad-hoc polymorphism in Scala ends up being more flexible than standard subtype polymorphism in a lot of ways, but one thing it's weak in is grouping items that are related by a type class. For example:
trait B:
def show = "hi"
class C extends B
class D extends B
val list: List[B] = List(C(), D())
list.map(_.show)
class E
object E:
given Show[E] with
def show(e: E) = "E"
class F
object F:
given Show[F] with
def show(f: F) = "F"
val list2 = List(E(), F())
list2.map(???) // we want to use Show, but the evidence for the typeclass is lost by shoving both into a list
With a type I've named Container
, it's possible however, to preserve the type class information for the types when grouping them together:
trait Container[A[_]]:
type B
val b: B
given ev: A[B]
def use[C](fn: A[B] ?=> B => C): C = fn(b)
object Container:
inline def apply[A[_]](a: Any) = ${
applyImpl[A]('a)
}
private def applyImpl[A[_]](a: Expr[Any])(using Quotes, Type[A]) =
import quotes.reflect.*
a match
case '{ $a: i } =>
TypeRepr.of[i].widen.asType match
case '[j] =>
val expr = Expr
.summon[A[j]]
.getOrElse(
report.errorAndAbort(
s"Can't find instance of ${Type.show[A[j]]} for ${Type.show[j]}"
)
)
'{
new Container[A]:
type B = j
val b: B = ${a.asExprOf[j]}
given ev: A[j] = $expr
}
val list2: List[Container[Show]] = List(Container[Show](E()), Container[Show](F()))
list2.map(_.use(_.show)) //List("E", "F")
This Container
type maintains the information about the type class by storing the type class implementation in a trait, and by proving to the Scala compiler that the data contained in the container is compatible with the type class.
The use
method located on the container type makes using the data in the container easy. The evidence of the type class is provided by the context function A[B] ?=> B => C
.
A helper macro is provided in the companion object of Container
in order to give a nicer syntax that doesn't require us to provide the soon-to-be erased type of the data that has a type class implementation.
Hope this helps anyone that wants to group data by type class! Happy Scala Hacking!!