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:

  1. Get the simple name of case class and remember it as name
  2. Get list of parameters and serialize them to a list of s"${parameter.name} = ${Show.show(parameter.value)}"
  3. 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