Dérivation récursive de typeclass avec Scala 3

Dans un précédent article, nous avions parlé de dérivation de typeclass en Scala 2 avec Magnolia. Nous avions vu qu’il est possible de dériver des typeclass pour des sealed trait à l’aide de la méthode split. En revanche, nous avons aussi observé que dans les cas de dérivation récursive, il nous fallait forcer la dérivation de notre sealed trait.

Dans cet article, nous allons tâcher de montrer comment faire une dérivation récursive de typeclass avec magnolia en Scala 3.

Reprenons…

Imaginons que l’on ait le co-produit suivant :

sealed trait A
case object B(str: String) extends A
case class C(next: A)      extends A
Figure 1 : Trait A et case class / objects

On souhaite dériver la typeclass Show suivante pour toute instance de A :

trait Show[T] {
  def show(t: T): String
}

trait ShowObj[T] extends Show[T] {
  def showObj(t: T): String
}

On peut se demander pourquoi nous avons Show et ShowObj ?

Ce pattern peut être utile lorsque l’on ne souhaite pas implémenter des comportements spécifiques à un type.

Par exemple, on peut imaginer que l’on ait besoin de Show pour afficher les types primitifs sur la console (String,Int…) et ShowObj pour afficher les case class

Figure 2 : Schéma de dérivation de typeclass qui explique pourquoi avoir Show et ShowObj

Dans notre exemple, on va essayer de dériver Show de manière à obtenir :

  • Show[String]
  • Show[Int]
  • ShowObj[CaseClassOne]
  • ShowObj[CaseClassTwo]

Maintenant, projetons notre exemple sur notre Trait A

Figure 3 : Schéma de dérivation de typeclass pour l’ADT de la Figure 1

On remarque que l’on va souhaiter obtenir les dérivations suivantes :

  • Show[String]
  • ShowObj[A] → va être obtenu par la méthode split
  • ShowObj[B]
  • ShowObj[C]

On peut voir avec la figure 3 que Show[C] va être récursive.

En effet, C contient un A qui peut être un C qui contient un A

Nous avons vu qu’avec Magnolia en Scala 2, la dérivation récursive de typeclass semble avoir des bugs. Partons d’un exemple rapide proche de ce que nous voulons faire.

trait Tc[T]

trait TcObj[T] extends Tc[T]

object ShowDerivation {
  type Typeclass[T] = Tc[T]

  def join[T](ctx: CaseClass[Tc, T]): TcObj[T] = new TcObj[T] {}
  def split[T](ctx: SealedTrait[Tc, T]): TcObj[T] = new TcObj[T] {}

  def gen[T]: Tc[T] = macro Magnolia.gen[T]
  def genObj[T]: TcObj[T] = macro Magnolia.gen[T]
}

Voici l’utilisation de ce code.

ShowDerivation.gen[B.type]    // success ☑️

ShowDerivation.genObj[B.type] // success ☑️

ShowDerivation.gen[A]         // success ☑️

//ShowDerivation.genObj[A]    // failure ❌
/*
type mismatch;
 found   : Playground.ShowDerivation.Typeclass[Playground.A]
    (which expands to)  Playground.Tc[Playground.A]
 required: Playground.TcObj[Playground.A]
*/

implicit val tca:Tc[A] = null

ShowDerivation.gen[C]        // success ☑️

ShowDerivation.genObj[C]     // success ☑️

Qu’en est-il en Scala 3 ?

On est toujours obligé de forcer la dérivation. La documentation de Magnolia l’explique ici : https://github.com/softwaremill/magnolia#limitations.

Un exemple

Dans cet exemple, l’idée est de reprendre le problème de la figure 2, mais en Scala 3.

On va tâcher de dériver la typeclass Show pour le trait suivant :

  sealed trait Kind
  case class Human(name: String) extends Kind
  case class Elf(elvishName: String) extends Kind
  case class Hybrid(kind: Kind, other: Kind) extends Kind

Le type Kind représente donc soit des humains, soit des elfes, soit des êtres hybrides (avec deux noms ^^).

Reprenons le code de la figure 4 pour le porter en Scala 3 :

object Show extends Derivation[Show]{

  override def join[T](ctx: CaseClass[Show, T]): ShowObj[T] = new ShowObj[T] {???}

  override def split[T](ctx: SealedTrait[Show, T]): ShowObj[T] = new ShowObj[T] {???}

  inline given autoDerived[A](using Mirror.Of[A]): Show[A] =
    derived.asInstanceOf[Show[A]]

  inline given derivedObj[A](using Mirror.Of[A]): ShowObj[A] =
    derived.asInstanceOf[ShowObj[A]]
}

Comme on l’a vu dans la Figure 3 nous allons avoir besoin de Show[String] pour afficher les String sur la console

  given Show[String] with
    def show(t: String): String = s"$t"

Désormais, nous avons un moyen d’afficher nos String et le squelette principal de notre dérivation.

Il nous faut alors implémenter split et join qui vont nous permettre de dériver les typeclass pour les sealed trait et les case class :

  override def join[T](ctx: CaseClass[Show, T]): ShowObj[T] = new ShowObj[T] {
    override def show(t: T): String = t.toString.toUpperCase()

    override def showObj(t: T): String =
      ctx.params.map(param => param.typeclass match {
        case showObj: ShowObj[param.PType] =>
          s"${param.label} part: [ ${showObj.showObj(param.deref(t))} ] "
        case show: Show[param.PType] =>
          s"${param.label}: [${show.show(param.deref(t))}]"
      }).mkString
  }

  override def split[T](ctx: SealedTrait[Show, T]): ShowObj[T] = new ShowObj[T] {
    override def show(t: T): String = ctx.choose(t) { sub =>
      sub.typeclass.show(sub.cast(t))
    }
    override def showObj(t: T): String = ctx.choose(t) { sub =>
      val tc = sub.typeclass.asInstanceOf[ShowObj[T]]
      tc.showObj(sub.cast(t))
    }
  }

Arrêtons-nous sur deux choses :

  1. Le showObj de join :
    • On regarde si on souhaite dériver un Show ou un ShowObj de notre case class (en l’occurrence, on voudra un Show, si on a un type primitif, ou un ShowObj, si on a une case class).
    • On formate correctement les valeurs des paramètres en les déréférençant, c'est-à-dire en récupérant leur valeur “réelle”.
  2. Le showObj de split :
    • On parcourt tous les sous-types d’un sealed trait (en l’occurrence, les sous-types de Kind) pour trouver celui que l’on cherche à dériver.
    • On cast notre sous-type dans son type réel (eg. j’ai un sous-type de Kind, si je vois que son type réel est Elf, alors je le cast en Elf) et on appelle showObj pour ce bon type.

Utilisons tout cela désormais.

val aelfraed: Kind = Elf("Aelfraed")
val maeva: Kind = Human("Maeva")
val kind :Kind = Hybrid(aelfraed, maeva)

given instance2: Show[Kind] = Show.derived

val showKind: Show[Kind] = summon[Show[Kind]]
println(s"1 - ${showKind.show(kind)}")

given instance3: ShowObj[Kind] = Show.derivedObj

val showObjKind: ShowObj[Kind] = summon[ShowObj[Kind]]
println(s"2 - ${showObjKind.showObj(kind)}")

Ce qui donne.

1 - HYBRID(ELF(AELFRAED),HUMAN(MAEVA))
2 - kind part: [ elvishName: [Aelfraed] ] other part: [ name: [Maeva] ]

Si on retourne sur la Figure 3 on voit que l’on a bien obtenu :

  • Show[String]
  • ShowObj[A] → obtenu par la méthode split
  • ShowObj[B]
  • ShowObj[C]

Et que Show[T] peut aussi dériver un A (cf. HYBRID(ELF(AELFRAED),HUMAN(MAEVA))) comme indiqué dans la Figure 2.

Voilà le code exécutable dans une worksheet, happy hacking!

import language.experimental.macros
import magnolia1.*

import scala.deriving.Mirror

trait Show[T] {
  def show(t: T): String
}
trait ShowObj[T] extends Show[T] {
  def showObj(t: T): String
}

object Show extends Derivation[Show]{
  given Show[String] with
    def show(t: String): String = s"$t"

  given Show[Int] with
    def show(t: Int): String = s"#${t.toString} -"

  override def join[T](ctx: CaseClass[Show, T]): ShowObj[T] = new ShowObj[T] {
    override def show(t: T): String = t.toString.toUpperCase()
    override def showObj(t: T): String =  ctx.params.map(param => param.typeclass match {
      case showObj: ShowObj[param.PType] => s"${param.label} part: [ ${showObj.showObj(param.deref(t))} ] "
      case show: Show[param.PType] => s"${param.label}: [${show.show(param.deref(t))}]"
    }).mkString
  }

  override def split[T](ctx: SealedTrait[Show, T]): ShowObj[T] = new ShowObj[T] {
    override def show(t: T): String = ctx.choose(t) { sub =>
      sub.typeclass.show(sub.cast(t))
    }
    override def showObj(t: T): String = ctx.choose(t) { sub =>
      val tc = sub.typeclass.asInstanceOf[ShowObj[T]]
      tc.showObj(sub.cast(t))
    }
  }

  inline given autoDerived[A](using Mirror.Of[A]): Show[A] = derived.asInstanceOf[Show[A]]
  inline given derivedObj[A](using Mirror.Of[A]): ShowObj[A] = derived.asInstanceOf[ShowObj[A]]
}

Et voici l’utilisation.

import Show.given_Show_String

sealed trait Root
case object B extends Root
case class C(next:Root) extends Root

given instance: Show[Root] = Show.derived

val showA: Show[Root] = summon[Show[Root]]
val root: Root = C(C(B))
println(showA.show(root))

sealed trait Kind
case class Human(name: String) extends Kind
case class Elf(elvishName: String) extends Kind
case class Hybrid(kind: Kind, other: Kind) extends Kind

val aelfraed: Kind = Elf("Aelfraed")
val maeva: Kind = Human("Maeva")
val kind :Kind = Hybrid(aelfraed, maeva)

given instance2: Show[Kind] = Show.derived

val showKind: Show[Kind] = summon[Show[Kind]]
println(showKind.show(kind))

given instance3: ShowObj[Kind] = Show.derivedObj
val showObjKind: ShowObj[Kind] = summon[ShowObj[Kind]]
println(showObjKind.showObj(kind))

Ressources

code de l’article : https://github.com/univalence/recursive-derivation-magnolia/blob/main/src/main/scala/ShowDerivationMagnolia3.scala