Scala 3 landed with new mechanisms and features that enable the derivation of type class instances without using macros or third-party libraries. In Automatically Deriving Typeclass Instances in Scala 3, we can see how to use these new tools, such as Mirrors, tuples, or inlining, to derive instances of the Show type class.
Although these new types and features introduced in the language are very powerful, they are still too low-level. Therefore, you need to know their nuts and bolts and how to connect them to write the derivation logic. In fact, if you look at the official documentation, it points out that this type class derivation framework is intentionally small and low-level. Moreover, its authors predict that it could be more helpful for building higher-level libraries than for general code. These high-level libraries will create more ergonomic solutions for the users, hiding all the low-level details. Shapeless 3 and Magnolia are examples of these kinds of libraries. This post will use the well-known Show
type class to demonstrate how to derive instances with Shapeless 3.
The Show
type class provides a way of transforming a type into a string. You can see it as an alternative to the toString
method. Let’s review what it looks like.
trait Show[A]:
def show(a: A): String
The first step to derive instances for complex types is to write instances for the basic types we want to support.
object Show:
given Show[Int] = _.toString
given Show[Boolean] = _.toString
given Show[String] = identity(_)
As you can see, we have defined the instances in the Show’s companion object. This is a good practice when defining and deriving type classes because you will have in scope the instances any time you use the Show type without an extra import. However, if you don’t own the type class or have other requirements, the instances and the derivation logic could be defined in a separate object or trait. But keep in mind that, in this case, you will need to import or extend it.
As usual, to use the Shapeless 3 library, we need to add it as a dependency in our project.
"org.typelevel" %% "shapeless3-deriving" % "version"
The version used for this post is 3.3.0
.
Additionally, we need to add the following import:
import shapeless3.deriving.*
Once we have the basic instances defined and everything prepared for using Shapeless 3, the next move is to derive Show instances for product types, usually represented as case classes or tuples in Scala.
def deriveShowProduct[A](using
pInst: K0.ProductInstances[Show, A],
labelling: Labelling[A]
): Show[A] =
(a: A) =>
labelling.elemLabels.zipWithIndex
.map { (label, index) =>
val value = pInst.project(a)(index)([t] => (st: Show[t], pt: t) => st.show(pt))
s"$label = $value"
}
.mkString(s"${labelling.label}(", ", ", ")")
This derivation algorithm iterates over all the product field names obtained from Labelling
, zipped with their indexes to get a string for each one. This string is composed of the field’s label and the string projection of the field’s value. To get the field’s value’s string projection, we are using the project
method defined in ProductInstances
, which has the following signature:
inline def project[R](t: T)(p: Int)(f: [t] => (F[t], t) => R): R
This method lets us take one field of a product type t
, which will be selected by its index p
, and transform it into another type R
through f
. As you might notice, f
isn’t a usual lambda; this is because it is a Polymorfic function type, a new type introduced in Scala 3, which describes a function that can have type parameters. This function allows using the type class instance F[t]
, Show[t]
in this case, and the field’s value we have selected to transform it.
Finally, we display all field strings in a string using the mkString
method.
ProductInstances
and Labelling
are two high-level components Shapeless provides to help us derivate type class instances. The former introduces a bunch of primitives, such as project
, construct
, or foldLeft
, among others, making writing the derivation logic of many type classes very straightforward. As its name suggests, ProductInstances
is only available for product types, whereas we can get instances of Labelling
for both product and sum types. The latter component, Labelling
, provides useful information; the type’s name and the type members’ names.
Deriving instances for sum types, also called coproducts, and represented in Scala 3 as sealed traits or enums, is even easier than for product types.
def deriveShowSum[A](using cInst: K0.CoproductInstances[Show, A]): Show[A] =
(a: A) => cInst.fold(a)([a] => (st: Show[a], a: a) => st.show(a))
We just need to use the fold
method defined in CoproductInstances
. The fold
method allows using the corresponding type class instance of every coproduct’s member to show its value. As you might have guessed, the CoproductInstances
is analogous to ProductInstances
, but for sum types.
At this point, we have written the necessary code to create Show
instances for Product and Sum types. However, we’ve only defined two methods, and the compiler still can’t perform its magic to derive the instances automagically. To solve that, we need to write one small but essential method.
inline given derived[A](using gen: K0.Generic[A]): Show[A] =
gen.derive(deriveShowProduct, deriveShowSum)
The name of this method hasn’t been chosen randomly but to fulfill the contract of the Scala compiler derives
clause. That means this method will be used if the compiler finds derives Show
in any of your types. Moreover, there is one more situation where this method will be called automatically; when the compiler needs to summon an instance of Show[A]
since it’s a given.
But what does this method do? Well, it is simple. It decides what derivation logic to use depending on the type. If A
is a product type, it will use the deriveShowProduct
method to derive the instance; instead, the deriveShowSum
will be used if A
is a Sum type.
The diagram below shows the high-level flow the Scala compiler will follow when it needs to summon an instance of Show for a specific type.
In the derived
method, we are using another type class, Generic
, implemented by Shapeless, which makes the derives method’s implementation very simple. Of course, Generic
has more uses, and behind the scenes, it uses advanced type level mechanisms. If you have used Shapeless 2, this type should be familiar to you.
Putting it all together:
import shapeless3.deriving.*
trait Show[A]:
def show(a: A): String
object Show:
given Show[Int] = _.toString
given Show[Boolean] = _.toString
given Show[String] = identity(_)
def deriveShowProduct[A](using
pInst: K0.ProductInstances[Show, A],
labelling: Labelling[A]
): Show[A] =
(a: A) =>
labelling.elemLabels.zipWithIndex
.map { (label, index) =>
val value = pInst.project(a)(index)([t] => (st: Show[t], pt: t) => st.show(pt))
s"$label = $value"
}
.mkString(s"${labelling.label}(", ", ", ")")
def deriveShowSum[A](using
inst: K0.CoproductInstances[Show, A]
): Show[A] =
(a: A) => inst.fold(a)([a] => (st: Show[a], a: a) => st.show(a))
inline given derived[A](using gen: K0.Generic[A]): Show[A] =
gen.derive(deriveShowProduct, deriveShowSum)
We can’t end without trying out our derivation code.
final case class Foo(x: Int, y: String, z: Boolean)
enum ColorEnum:
case Red, Green, Blue
println(summon[Show[Foo]].show(Foo(1, "s", true)))
println(summon[Show[ColorEnum]].show(ColorEnum.Blue))
The result we get is:
"Foo(x = 1, y = s, z = true)"
"Blue()"
As we have seen, Shapeless 3 provides high-level abstractions for deriving type class instances simply and elegantly for Scala 3. Under the hood, it uses the new framework for type class derivation and other compile-time and metaprogramming facilities of Scala 3.
The Github repo for this post contains all the code shown here. Additionally, it includes the code for deriving instances of Show using only Scala 3, so you can compare both approaches and play with them. Have fun!