Scala 3's Type System, Part 6: Type Class Derivation
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:
- Because a given in the type class companion is always in implicit scope, an instance is re-derived at every use site. Encode
Orderin 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. - 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.
encodeProductandencodeSumabove takeelems: => List[JsonEncoder[?]], andderivedbinds them withlazy val. Drop either andcase class Comment(body: String, replies: List[Comment]) derives JsonEncodercompiles fine and then hangs or throwsStackOverflowErrorthe 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.