Generic Derivation - a comparison
In a previous post I’ve shown how to build a minimal example of a random case class generator using the metaprogramming features of Scala 3. While investigating this topic, I naturally came upon multiple ways to do this. In this post I will elaborate on two other ways to build the same generator and pick my personal favorite.
The previous example
trait Generator[A]:
def generate: A
object Generator:
def create[A](a: => A) = new Generator[A]:
override def generate: A = a
// Simple instances needed for all methods used in this post
given int: Generator[Int] = create(Random.nextInt())
given double: Generator[Double] = create(Random.nextDouble())
given bool: Generator[Boolean] = create(Random.nextBoolean())
private val lorumWords = "lorum ipsum, etc.".split("[.|,]? ")
given string: Generator[String] = create(lorumWords(Random.between(0, lorumWords.length)))
// Specific code for this method below
given empty: Generator[EmptyTuple] = create(EmptyTuple)
given tuple[H, T <: Tuple](using hGen: Generator[H], tGen: Generator[T]): Generator[H *: T] =
create(hGen.generate *: tGen.generate)
given product[P <: Product](using m: Mirror.ProductOf[P], ts: Generator[m.MirroredElemTypes]): Generator[P] = create {
val representation = ts.generate
m.fromProduct(representation)
}
Above code shows how we would have solved generic derivation using the shapeless library in scala 2.x (here replaced with scala 3 constructs[3]). It is based on implicit methods called recursively until a Generator for each field is found. Only then will this code compile when asked for a Generator.
A Scala 3 alternative
Scala 3 now contains all the tools to build this Generator without using an external library like shapeless.
Moreover, it also provides us with more advanced tools like inlining, and compile time type matching.
Previously, we’ve seen that a case class can be described as a N-arity Tuple
like (N1, N2, …, N)
, or equivalent, a recursive structure N1 *: N2 *: .. *: N *: EmptyTuple
.
What Scala 3 adds to its metaprogramming tools is the ability to match on these Tuples
. To use this at compile time, the compiler needs to
1. be able to inline our code
2. cope with type erasure by calling erasedValue
on the Type we need.
object Hello:
inline def hello[A]: Unit =
inline erasedValue[A] match
case _: Int => println("Hello Int")
case _: String => println("Hello String")
case _: (tpe *: types) => hello[tpe]; hello[types]
case EmptyTuple => println("That's it")
hello[(String, Int)]
When ran, this code will print:
Hello String
Hello Int
Thats it
What’s so special about this, is the matching clause on case _: (tpe *: types)
, where tpe
and types
are the types matched in the Tuple
, not its values, and may be used as type parameters again.
When building our generator, we could use this construct again to recursively summon a Generator
for these types using another inline construct: summonInline
:
// General trait, object and instances as shown above
// Specific code for this method below
inline def tuple[Types <: Tuple](ts: Tuple): Tuple =
inline erasedValue[Types] match
case EmptyTuple => ts
case _: (tpe *: types) =>
val value = summonInline[Generator[tpe]].generate
val values = ts :* value
tuple[types](values)
This way of building the Generator[Tuple]
removes some of the magic of resolving implicits, that we needed in the shapeless-like example above. summonInline
does exactly as it promises, it either finds a Generator
in scope for the first type of the matched Tuple tpe
, or fails to compile.
Scala 3 adds one other feature for auto-deriving a Generator
type-class, however. If we add a method called derived
, we’re able to use the clause derives
to derive a Generator[Person]
in its companion object for free!
inline def derived[P <: Product](using m: Mirror.ProductOf[P]): Generator[P] = new Generator[P]:
def generate: P =
val representation = tuple[m.MirroredElemTypes](EmptyTuple)
m.fromProduct(representation)
case class Person(name: String, age: Int) derives Generator
Again, we use the Mirror
of Product P
to generate its representation, and generate a P
from it using said Mirror
.
The full example can be found here.
Magnolia for better ergonomics and readability
Recently I watched a very inspiring Zymposium video by Kit Langton, who showed a promising method of deriving a generic Differ
for case classes using magnolia. Using the library looked so simple, I had to try that on top of the vanilla Scala 3 method to create our Generator
.
Magnolia offers a macro for type-class derivation, listed on their Github. There are two methods we need complete for a full example. However, that also covers sum-types, which for this purpose we’re not interested in, yet. Let’s complete the example for our Generator
:
object Generator:
def join[T](ctx: CaseClass[Generator, T]): Generator[T] = new Generator[T]:
override def generate: T = ctx.construct(param => param.typeclass.generate)
// instances for simple types go here
That’s right, this became a one-liner using magnolia, when we forget the simple instances we also needed for the other examples. Magnolia’s macro looks for the method join
, that needs a CaseClass
context for our type-class to derive. Similar to Mirror.fromProduct
before, the CaseClass context
is able to create case classes from their representation.
Magnolia however paved the way for us by providing the parameter we are going to generate a value for. That parameter contains a typeclass
field to access a Generator
in scope, much like summonInline
. The CaseClass context loops over each parameter of the case class under construction, and our Generator does the rest.[4].
Since the Product
case is so simple, let’s also cover the sum-type case now, to generate sealed traits as well.
object Generator:
override def split[T](ctx: SealedTrait[Generator, T]): Generator[T] = new Generator[T]:
override def generate: T =
val subs = ctx.subtypes
val pick = Random.between(0, subs.size)
ctx.subtypes.apply(pick).typeclass.generate
// instances for simple types go here
The SealedTrait
context provides us with all the subtypes we need to pick from with subtypes
. We then roll a dice and pick one, ask for the typeclass
like we did in the Product
case, and generate it. If that subtype is a Product
, the join
method above provides all means to generate it, and vice versa, when a parameter of a Product
is a sealed trait.
Finally, Magnolia adds an AutoDerivation
trait to add an instance of Generator
to our case class (providing a def derived
):
object Generator extends AutoDerivation[Generator]:
case class Person(name: String, age: Int) derives Generator
Conclusion
Magnolia offers a great way to start generating your own type-classes. Even though Scala 3 adds metaprogramming constructs out of the box, the ease-of-use and readability the library adds to vanilla Scala makes this easier and more understandable even for less experienced programmers.
Code
The code for this blog can be found on Gitlab
Resources
Below blogs and videos greatly helped me understand the topic of generic programming: