Intermediate's guide to derivations in Scala: Magnolia
In the previous post about Scala derivation I’ve explained the idea of derivation and showed how we can benefit from it by using library-provided derivations. This time let’s dig deeper and implement our own derviation with Magnolia.
TL;DR This post is intended for intermediate Scala users, if you are not familiar with the topic I recommend starting with the beginner introduction first
♻️ Recap
TL;DR you can skip this section if you remember the previous post, it’s just a recap
In the previous post we have been working with the Show
type class that looks like this:
trait Show[A] {
def show(a: A): String
}
We have provided our own implementation of the type class for some existing types and our own Person
case class.
object Show {
given Show[String] = new Show[String] {
def show(a: A): String
}
given Show[Int] = new Show[Int] {
def show(a: Int): String = a.toString()
}
extension [A: Show](a: A) {
def show: String = Show[A].show(a)
}
}
val ShowPerson = new Show[Person] {
def show(a: Person): String =
s"Person(name = ${a.name.show}, surname = ${a.surname.show}, age = ${a.age.show})"
}
It quickly became obvious that there’s a pattern for generating those show
implementations and there should be a way to make the compiler generate the implementation. We have went for an existing implementation from the kittens library.
🌼 Magnolia
In this post let’s explore the Magnolia and try to implement the Show
derviation ourselves. What’s Magnolia? Quoting their readme:
Magnolia is a generic macro for automatic materialization of typeclasses for datatypes composed from product types (e.g. case classes) and coproduct types (e.g. enums). It supports recursively-defined datatypes out-of-the-box, and incurs no significant time-penalty during compilation.
Sounds complex? From the practical point of view it means that we are going to implement something like:
object givens extends AutoDerivation[Show] {
// generate Show instance for case classes
override def join[T](caseClass: CaseClass[Show, T]): Show[T] = ???
// generate Show instance for sealed traits
override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] = ???
}
The extends AutoDerivation[Show]
means that Magnolia is supposed to generate the type class instances for show
assuming we have provided join
and split
.
🛠️ Generate Show for case classes
Let’s start with implementing join
by taking a look at what does the CaseClass
interface prove:
abstract class CaseClass[Typeclass[_], Type](
val typeInfo: TypeInfo,
val isObject: Boolean,
val isValueClass: Boolean,
val parameters: IArray[CaseClass.Param[Typeclass, Type]],
val annotations: IArray[Any],
val inheritedAnnotations: IArray[Any] = IArray.empty[Any],
val typeAnnotations: IArray[Any]
)
When generating the Show
for any case class we’d like to follow following steps:
- Get the simple name of case class and remember it as
name
- Get list of parameters and serialize them to a list of
s"${parameter.name} = ${Show.show(parameter.value)}"
- Combine them together into a string
To obtain the name of our case class, we can have a look at the typeInfo
field, it’s type is defined as
case class TypeInfo(
owner: String,
short: String,
typeParams: Iterable[TypeInfo]
)
The short
name is what we are looking for. Let’s use this knowledge and start implementing join
override def join[T](caseClass: CaseClass[Show, T]): Show[T] =
new Show[T] {
def show(value: T) = {
val name = caseClass.typeInfo.short
val serializedParams = ???
s"$name($serializedParams)"
}
}
We’re half way there, now let’s see about serializedParam
. We’ll obtain the necessary information from parameters
field. Let’s have a look at CaseClass.Param
interface
trait Param[Typeclass[_], Type](
val label: String,
val index: Int,
val repeated: Boolean,
val annotations: IArray[Any],
val typeAnnotations: IArray[Any]
)
The first part, parameter name, is very easy to obtain, it’s available under the label
field. How about the parameter value? It’s not the inherent parameter of a case class field, so it’s not in the constructor. The Param
interface provides the deref
method:
/**
* Get the value of this param out of the supplied instance of the case class.
*
* @param value an instance of the case class
* @return the value of this parameter in the case class
*/
def deref(param: Type): PType
Seems we have got exactly what we need for obtaining parameter value, provided we can supply the case class instance as a param
. Let’s use this API to fill in the gap in serializedParams
:
override def join[T](caseClass: CaseClass[Show, T]): Show[T] =
new Show[T] {
def show(value: T) = {
val name = caseClass.typeInfo.short
val serializedParams = caseClass.parameters.map { parameter =>
s"${parameter.label} = ${parameter.typeclass.show(parameter.deref(value))}"
}.mkString(", ")
s"$name($serializedParams)"
}
}
That’s all, we have mapped the parameters to an array of key = value
strings with
s"${parameter.label} = ${parameter.typeclass.show(parameter.deref(value))}"
and then combined them together with mkString
. This pretty much resembles the 3 point plan outlined above.
With the join
covered, let’s move to split
.
🛠️ Generate Show for sealed traits & enums
The method we are about to implement has a following signature:
override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] = ???
Let’s again have a look at what we can find out by exploring SealedTrait
/**
* Represents a Sealed-Trait or a Scala 3 Enum.
*
* In the terminology of Algebraic Data Types (ADTs), sealed-traits/enums are termed
* 'sum types'.
*/
case class SealedTrait[Typeclass[_], Type](
typeInfo: TypeInfo,
subtypes: IArray[SealedTrait.Subtype[Typeclass, Type, _]],
annotations: IArray[Any],
typeAnnotations: IArray[Any],
isEnum: Boolean,
inheritedAnnotations: IArray[Any]
)
🤔 The tempting approach
We can see some familiar faces here, we have used typeInfo
already. The first thing I did when learning Magnolia was to implement split
like this:
override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] =
sealedTrait.typeInfo.short
Seems tempting and neat, but this way we only receive the name of the top level trait. For example if we had an enum like:
enum Animal {
case Dog
case Cat
case Other(kind: String)
}
and invoked our derived Show
like this:
summon[Show[Animal]].show(Animal.Dog)
We’d receive Animal
when we’d expect Dog
.
✅ The correct approach
This is why we need to find out which exact subtype are we working with. Luckily along the fields we have already seen, SealedTrait
provides the choose
method:
/**
* Provides a way to recieve the type info for the explicit subtype that
* 'value' is an instance of. So if 'Type' is a Sealed Trait or Scala 3
* Enum like 'Suit', the 'handle' function will be supplied with the
* type info for the specific subtype of 'value', eg 'Diamonds'.
*
* @param value must be instance of a subtype of typeInfo
* @param handle function that will be passed the Subtype of 'value'
* @tparam Return whatever type the 'handle' function wants to return
* @return whatever the 'handle' function returned!
*/
def choose[Return](value: Type)(handle: Subtype[_] => Return): Return
It does exactly the thing we are looking for - obtaining a subtype of our ADT. How should our handle
method look like? It should invoke the show
method on the subtype because it might be a case class, and the show
should be recursive.
In pseudo-code we are looking for something like:
sealedTrait.choose(value){ subtype =>
Show[subtype.Type].show(value)
}
This means we need to learn how to request the type class instance for our subtype. This can be done by invoking subtype.typeclass
. This means our next iteration would be
sealedTrait.choose(value){ subtype =>
subtype.typeclass.show(value)
}
It doesn’t work just yet, the compiler throws an error:
Found: (value : T)
Required: subtype.S & T
It happened because the type class for subtype only works for the subset of our initial ADT. Since this method is only called if our provided value matches this subtype, we can safely cast the value. This can be done using subtype.cast(value)
sealedTrait.choose(value){ subtype =>
subtype.typeclass.show(subtype.cast(value))
}
With that knowledge, this is how we can implement the join
method with what we have learned
override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] =
new Show[T] {
def show(value: T): String =
sealedTrait.choose(value){ subtype =>
subtype.typeclass.show(subtype.cast(value))
}
}
🖇 Combine them altogether
Since our building blocks are done, let’s glue them together:
import magnolia1.*
object Show {
object givens extends AutoDerivation[Show] {
given Show[String] = value => value
given [A](using Numeric[A]): Show[A] = _.toString
// generate Show instance for case classes
override def join[T](caseClass: CaseClass[Show, T]): Show[T] =
new Show[T] {
def show(value: T) = {
val name = caseClass.typeInfo.short
val serializedParams = caseClass.parameters.map { parameter =>
s"${parameter.label} = ${parameter.typeclass.show(parameter.deref(value))}"
}.mkString(", ")
s"$name($serializedParams)"
}
}
// generate Show instance for sealed traits
override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] =
new Show[T] {
def show(value: T): String =
sealedTrait.choose(value){ subtype =>
subtype.typeclass.show(subtype.cast(value))
}
}
}
}
Along the two methods required by Magnolia, I’ve added givens for String
and Numerics
as for basing building blocks of more complex types.
We can test the produced code by adding a main
method and adding some test data structures.
case class MyCaseClass(number: Long, name: String)
enum Animal {
case Dog
case Cat
case Other(kind: String)
}
@main
def main() = {
import Show.givens.given
println(
summon[Show[MyCaseClass]].show(MyCaseClass(number = 5, name = "test"))
)
println(
summon[Show[Animal]].show(Animal.Dog)
)
println(
summon[Show[Animal]].show(Animal.Other("snake"))
)
}
Notice how we never provide an explicit implementation of Show
for our custom types, the import Show.givens.given
has got it all covered.
The example code is also available at https://github.com/majk-p/derive-show-with-magnolia