Scala 3's Type System, Part 6: Type Class Derivation

scalatype-systems

Part 5 left us with a JsonEncoder type class and a promise to write one instance per type. That is fine for three types and miserable for forty, and the failure mode is worse than the tedium: someone adds a field, forgets the encoder, and the API silently stops emitting it. Scala 3 can generate those instances from the case class definition itself.

πŸ”—The boilerplate, and why it’s dangerous

Three types, three encoders, all pure transcription:

enum Json:
  case JNull
  case JStr(value: String)
  case JNum(value: Double)
  case JArr(items: List[Json])
  case JObj(fields: List[(String, Json)])

trait JsonEncoder[A]:
  def encode(a: A): Json

given stringEncoder: JsonEncoder[String] = Json.JStr(_)
given longEncoder: JsonEncoder[Long] = n => Json.JNum(n.toDouble)

extension [A](a: A)(using e: JsonEncoder[A]) def toJson: Json = e.encode(a)

final case class Address(street: String, city: String, postcode: String)
final case class Customer(id: String, email: String, billing: Address)
final case class Order(id: String, customer: Customer, amountCents: Long)

given addressEncoder: JsonEncoder[Address] = a =>
  Json.JObj(List(
    "street"   -> a.street.toJson,
    "city"     -> a.city.toJson,
    "postcode" -> a.postcode.toJson
  ))

given customerEncoder: JsonEncoder[Customer] = c =>
  Json.JObj(List(
    "id"      -> c.id.toJson,
    "email"   -> c.email.toJson,
    "billing" -> c.billing.toJson
  ))

given orderEncoder: JsonEncoder[Order] = o =>
  Json.JObj(List(
    "id"          -> o.id.toJson,
    "customer"    -> o.customer.toJson,
    "amountCents" -> o.amountCents.toJson
  ))

Now add discountCents: Long to Order. The constructor call sites break, so you fix those. orderEncoder does not break β€” it still compiles, and it still emits three fields. The new field is dropped from every API response and every event you publish, and nothing tells you. That is not a hypothetical; it is the single most common bug in hand-maintained codec layers.

The information needed to write these encoders is already in the case class. The compiler should hand it over.

πŸ”—Scala 2 had to work for this

In Scala 2 the answer was shapeless: convert the case class to an HList via a Generic macro, build the instance inductively over the HList, convert back. It worked, and it was heavy β€” long compile times, implicit-resolution errors that ran to dozens of lines, and a hard dependency on a library doing macro work the language didn’t officially support.

Scala 3 moved the essential piece into the compiler.

πŸ”—Mirror: the compiler already knows the shape

For every case class and every sealed hierarchy, the compiler can synthesize a scala.deriving.Mirror on demand. It carries the structure as types:

import scala.deriving.Mirror
import scala.compiletime.{constValue, constValueTuple}

final case class Address(street: String, city: String, postcode: String)

enum Payment:
  case Card(last4: String, amountCents: Long)
  case BankTransfer(iban: String, amountCents: Long)

@main def run(): Unit =
  val p = summon[Mirror.ProductOf[Address]]
  println(constValue[p.MirroredLabel])          // Address
  println(constValueTuple[p.MirroredElemLabels]) // (street,city,postcode)

  val tupled: p.MirroredElemTypes = ("12 Rue Oberkampf", "Paris", "75011")
  println(p.fromProduct(tupled))                 // Address(12 Rue Oberkampf,...)

  val s = summon[Mirror.SumOf[Payment]]
  println(constValueTuple[s.MirroredElemLabels]) // (Card,BankTransfer)
  println(s.ordinal(Payment.BankTransfer("FR76", 4990L))) // 1

The members you care about:

MirroredLabel
the type’s own name, as a string literal type.
MirroredElemTypes
a tuple type. For a product, the field types. For a sum, the branch types.
MirroredElemLabels
a tuple of string literal types β€” field names, or branch names.

Mirror.ProductOf[A] adds fromProduct (build an A from a tuple); Mirror.SumOf[A] adds ordinal (which branch is this value?). Everything else is compile-time-only type information.

πŸ”—Walking a tuple type at compile time

MirroredElemTypes is a type, not a value, so you can’t map over it. You recurse over it with inline and erasedValue, which lets you pattern match on a type without ever producing a value of it:

import scala.compiletime.{constValue, erasedValue, summonInline}

trait JsonEncoder[A]:
  def encode(a: A): String

given stringEncoder: JsonEncoder[String] = s => "\"" + s + "\""
given longEncoder: JsonEncoder[Long] = _.toString

inline def summonAll[T <: Tuple]: List[JsonEncoder[?]] =
  inline erasedValue[T] match
    case _: EmptyTuple => Nil
    case _: (t *: ts)  => summonInline[JsonEncoder[t]] :: summonAll[ts]

inline def summonLabels[T <: Tuple]: List[String] =
  inline erasedValue[T] match
    case _: EmptyTuple => Nil
    case _: (t *: ts)  => constValue[t].toString :: summonLabels[ts]

@main def run(): Unit =
  println(summonLabels[("street", "city", "postcode")]) // List(street, city, postcode)
  println(summonAll[(String, Long, String)].size)       // 3

Three pieces do the work. erasedValue[T] is a compile-time-only expression of type T that must never be evaluated β€” inline match uses it purely to test the type. summonInline[JsonEncoder[t]] defers the implicit search until the inline expansion, when t is a concrete type. constValue[t] turns a singleton literal type back into a value, which is how field names escape the type level.

The result of summonAll[(String, Long, String)] is a plain List built at the call site. The types are gone by runtime; only the encoders remain.

πŸ”—derived and the derives clause

Put it together and you get a derived method. When a type declares derives JsonEncoder, the compiler generates given JsonEncoder[Order] = JsonEncoder.derived[Order] in that type’s companion object, passing the synthesized Mirror.

import scala.deriving.Mirror
import scala.compiletime.{constValue, erasedValue, summonInline}

enum Json:
  case JNull
  case JBool(value: Boolean)
  case JNum(value: Double)
  case JStr(value: String)
  case JArr(items: List[Json])
  case JObj(fields: List[(String, Json)])

trait JsonEncoder[A]:
  def encode(a: A): Json

object JsonEncoder:
  def apply[A](using e: JsonEncoder[A]): JsonEncoder[A] = e

  given stringEncoder: JsonEncoder[String] = Json.JStr(_)
  given longEncoder: JsonEncoder[Long] = n => Json.JNum(n.toDouble)
  given intEncoder: JsonEncoder[Int] = n => Json.JNum(n.toDouble)
  given boolEncoder: JsonEncoder[Boolean] = Json.JBool(_)
  given optionEncoder[A](using e: JsonEncoder[A]): JsonEncoder[Option[A]] =
    case Some(a) => e.encode(a)
    case None    => Json.JNull
  given listEncoder[A](using e: JsonEncoder[A]): JsonEncoder[List[A]] =
    as => Json.JArr(as.map(e.encode))

  inline def summonAll[T <: Tuple]: List[JsonEncoder[?]] =
    inline erasedValue[T] match
      case _: EmptyTuple => Nil
      case _: (t *: ts)  => summonInline[JsonEncoder[t]] :: summonAll[ts]

  inline def summonLabels[T <: Tuple]: List[String] =
    inline erasedValue[T] match
      case _: EmptyTuple => Nil
      case _: (t *: ts)  => constValue[t].toString :: summonLabels[ts]

  def encodeProduct[A](labels: List[String], elems: => List[JsonEncoder[?]]): JsonEncoder[A] =
    (a: A) =>
      val values = a.asInstanceOf[Product].productIterator.toList
      Json.JObj(labels.zip(values).zip(elems).map { case ((label, value), enc) =>
        label -> enc.asInstanceOf[JsonEncoder[Any]].encode(value)
      })

  def encodeSum[A](m: Mirror.SumOf[A], labels: List[String], elems: => List[JsonEncoder[?]]): JsonEncoder[A] =
    (a: A) =>
      val i = m.ordinal(a)
      Json.JObj(List(
        "type"  -> Json.JStr(labels(i)),
        "value" -> elems(i).asInstanceOf[JsonEncoder[Any]].encode(a)
      ))

  inline def derived[A](using m: Mirror.Of[A]): JsonEncoder[A] =
    lazy val elems = summonAll[m.MirroredElemTypes]
    val labels     = summonLabels[m.MirroredElemLabels]
    inline m match
      case s: Mirror.SumOf[A]     => encodeSum(s, labels, elems)
      case _: Mirror.ProductOf[A] => encodeProduct(labels, elems)

def render(json: Json): String = json match
  case Json.JNull       => "null"
  case Json.JBool(b)    => b.toString
  case Json.JNum(n)     => if n.isWhole then n.toLong.toString else n.toString
  case Json.JStr(s)     => "\"" + s + "\""
  case Json.JArr(items) => items.map(render).mkString("[", ",", "]")
  case Json.JObj(fs)    => fs.map((k, v) => "\"" + k + "\":" + render(v)).mkString("{", ",", "}")

final case class Address(street: String, city: String, postcode: String) derives JsonEncoder
final case class Customer(id: String, email: String, billing: Address) derives JsonEncoder

sealed trait Payment derives JsonEncoder
object Payment:
  final case class Card(last4: String, amountCents: Long) extends Payment derives JsonEncoder
  final case class BankTransfer(iban: String, amountCents: Long) extends Payment derives JsonEncoder

final case class Order(id: String, customer: Customer, payment: Payment, note: Option[String])
  derives JsonEncoder

@main def run(): Unit =
  val order = Order(
    "ord_991",
    Customer("cus_17", "ana@example.com", Address("12 Rue Oberkampf", "Paris", "75011")),
    Payment.Card("4242", 4990L),
    None
  )
  println(render(JsonEncoder[Order].encode(order)))
{"id":"ord_991","customer":{"id":"cus_17","email":"ana@example.com",
"billing":{"street":"12 Rue Oberkampf","city":"Paris","postcode":"75011"}},
"payment":{"type":"Card","value":{"last4":"4242","amountCents":4990}},
"note":null}

The inline m match in derived is the branch point. For a product, MirroredElemTypes is the field types and MirroredElemLabels the field names, so encodeProduct zips labels against productIterator. For a sum, the same two members mean something different β€” branch types and branch names β€” so encodeSum uses ordinal to pick the right encoder and writes a "type" discriminator alongside the payload. That discriminator choice is yours; wrapping in {"type": ..., "value": ...} is one convention, and flattening the discriminator into the object is another.

Add discountCents: Long to Order now and the encoder emits it, because there is no longer an encoder to forget to update.

πŸ”—Gotchas

Semi-auto means every node needs its own instance β€” including each branch of a sum. Notice that Card and BankTransfer each carry their own derives JsonEncoder. That is not decoration. derived[Payment] calls summonInline[JsonEncoder[Payment.Card]]. Write the same ADT as an enum β€” where the cases cannot carry their own derives clause β€” and nothing provides those instances:

No given instance of type JsonEncoder[Payment.Card] was found
enum Payment derives JsonEncoder:
                     ^

Which is why the sum in the example above is a sealed trait with case classes: each branch gets a companion object that can hold its own derived instance.

Making derived a given fixes that, and creates a bigger problem. Change one word:

// scala-check: skip
// full-auto: derived is now itself a given, so it applies recursively
inline given derived[A](using m: Mirror.Of[A]): JsonEncoder[A] = ...

Now enum Payment derives JsonEncoder compiles, because summonInline[JsonEncoder[Payment.Card]] resolves via derived itself. It also means any product or sum type is now silently encodable. Two consequences:

  1. Because a given in the type class companion is always in implicit scope, an instance is re-derived at every use site. Encode Order in twelve places and the compiler inlines the whole tree twelve times. On a large domain this is the difference between a 40-second and a 6-minute compile.
  2. A type you never meant to expose over the wire β€” an internal command, a config case class holding a secret β€” now serializes without you asking.

Semi-auto (inline def derived plus an explicit derives clause) costs one line per type and gives you one instance per type, cached as a lazy val in the companion. That is usually the right default.

Recursive types need the element instances passed by name. encodeProduct and encodeSum above take elems: => List[JsonEncoder[?]], and derived binds them with lazy val. Drop either and case class Comment(body: String, replies: List[Comment]) derives JsonEncoder compiles fine and then hangs or throws StackOverflowError the first time you use it, because building the instance requires the instance.

Error messages point at the derives clause, not the field. A missing instance deep in a nested model reports against the outer type:

No given instance of type JsonEncoder[java.time.Instant] was found
final case class Address(street: String, city: String, since: java.time.Instant)
  derives JsonEncoder

That one is readable. Three levels down, or inside a conditional given, the message names a type but not the path that led there. The reliable move is to summon the suspect instance manually at the top level and let the compiler complain about it directly.

Inline derivation is not free. Each derived call expands the full recursion at compile time. It costs compile time, not runtime β€” the emitted code is an ordinary lazy val holding an ordinary object β€” but the cost lands on whoever is waiting for the build. Derive once per type, in the companion, and let everyone else summon it.

πŸ”—Next up

summonAll[T <: Tuple] quietly abstracted over a type constructor, and JsonEncoder[?] used a wildcard to hold instances of different types in one list. Both are corners of a bigger topic: Part 7: Higher-Kinded Types and Type Lambdas.