Cutting Boilerplate with Scala 3's Deriving: A Practical Walkthrough
Scala 3's deriving mechanism uses compile-time Mirror introspection to generate type class instances for case classes and sealed traits automatically. This walkthrough shows a minimal JSON codec, explains product vs sum derivation, and covers compile-time costs and Scala 2 limitations.
07 Dec 2025, 22:14 UTC

The Problem: Type Class Boilerplate That Doesn't Scale
If you've written a JsonEncoder, Eq, or Show instance for a dozen case classes, you know the drill: each new data type demands a fresh given that manually picks apart fields, recurses into nested types, and handles optional values. The logic is mechanical, yet the compiler can't write it for you—at least not in Scala 2.
Scala 3 changes this with deriving, a compile-time mechanism that lets the compiler synthesize type class instances for product types (case classes) and sum types (sealed hierarchies) by inspecting their structure through scala.deriving.Mirror. The result is less code to maintain, fewer copy-paste bugs, and a single derivation definition that covers your entire domain model.
How Deriving Works: Mirror as the Compiler's Introspection API
At the heart of deriving is scala.deriving.Mirror, a type that the compiler automatically generates for every case class and sealed trait. A Mirror exposes two key pieces of information:
- Labels: the field names (for products) or constructor names (for sums) as a tuple of string literals.
- Element types: the types of each field or constructor argument, again as a tuple.
There are two mirror flavors:
Mirror.ProductOf[T]for case classes and tuples—thinkUser(name: String, age: Int).Mirror.SumOf[T]for sealed hierarchies—thinksealed trait Event; case class Login(user: String) extends Event.
When you define a type class with a derived given, the compiler summons the appropriate Mirror and hands you the structural metadata. Your job is to write one generic implementation that works for any product or sum, using the mirror's labels and element types to drive the logic.
Worked Example: A Minimal JSON Codec
Let's build a JsonFormat[T] type class that serializes to a simple JSON string. We'll keep the codec tiny so the derivation mechanics stay visible.
Step 1: Define the Type Class
trait JsonFormat[T]:
def write(value: T): String
def read(json: String): T
Step 2: Provide Primitive Instances
Deriving needs base cases. We'll supply instances for String, Int, and Option[T] (assuming a JsonFormat[T] exists).
given JsonFormat[String] with
def write(s: String) = s"\"$s\""
def read(json: String) = json.stripPrefix("\"").stripSuffix("\"")
given JsonFormat[Int] with
def write(i: Int) = i.toString
def read(json: String) = json.toInt
given [T](using fmt: JsonFormat[T]): JsonFormat[Option[T]] with
def write(opt: Option[T]) = opt.map(fmt.write).getOrElse("null")
def read(json: String) = if json == "null" then None else Some(fmt.read(json))
Step 3: Write the Derived Instance
This is where deriving shines. We pattern-match on the mirror kind.
import scala.deriving.Mirror
import scala.compiletime.{erasedValue, summonInline}
object JsonFormat:
inline given derived[T](using m: Mirror.Of[T]): JsonFormat[T] =
inline m match
case _: Mirror.ProductOf[T] => productFormat[T]
case _: Mirror.SumOf[T] => sumFormat[T]
private inline def productFormat[T](using m: Mirror.ProductOf[T]): JsonFormat[T] =
new JsonFormat[T]:
private val elemLabels = m.MirroredElemLabels
private val elemFormats = summonAll[m.MirroredElemTypes]
def write(value: T): String =
val elems = value.asInstanceOf[Product].productIterator.toArray
val fields = elemLabels.zip(elemFormats).zip(elems).map:
case ((label, fmt), elem) => s"\"$label\": ${fmt.write(elem.asInstanceOf[Any])}"
fields.mkString("{", ",", "}")
def read(json: String): T =
// Simplified: real impl would parse JSON properly
throw new NotImplementedError("Parsing omitted for brevity")
private inline def sumFormat[T](using m: Mirror.SumOf[T]): JsonFormat[T] =
new JsonFormat[T]:
private val ordinal = value.asInstanceOf[Matchable] match
case p: Product => p.productPrefix
case _ => ""
// Simplified: real impl would dispatch by constructor name
def write(value: T): String = s"\"$ordinal\""
def read(json: String): T = throw new NotImplementedError()
// Helper to summon a tuple of formats for all element types
private inline def summonAll[T <: Tuple]: List[JsonFormat[?]] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (h *: t) => summonInline[JsonFormat[h]] :: summonAll[t]
Step 4: Use It
case class User(name: String, age: Int, email: Option[String])
@main def demo() =
val user = User("Ada", 36, Some("[contact removed]"))
val json = summon[JsonFormat[User]].write(user)
println(json)
// Output: {"name": "Ada", "age": 36, "email": "[contact removed]"}
No manual given JsonFormat[User] required. The compiler constructs it by reading Mirror.ProductOf[User], which carries MirroredElemLabels = ("name", "age", "email") and MirroredElemTypes = (String, Int, Option[String]).
Sum Types: Sealed Hierarchies Work the Same Way
The same derived given handles sealed traits. For a hierarchy like:
sealed trait Event
case class Login(user: String) extends Event
case class Logout(user: String) extends Event
case object Heartbeat extends Event
Mirror.SumOf[Event] provides MirroredElemLabels = ("Login", "Logout", "Heartbeat") and MirroredElemTypes = (Login, Logout, Heartbeat.type). The sumFormat branch can serialize the constructor name and delegate to each case's product format. This uniformity—one derivation covering both products and sums—is a major ergonomic win over Scala 2's shapeless-based approaches.
Trade-offs and Limitations
Compile-Time Cost
Deriving moves work from runtime to compile time. For large hierarchies (hundreds of case classes across many type classes), you'll see longer compilation and larger bytecode. Profile with scalac -Xprint:typer or -Ystatistics to measure impact. If compile times become painful, consider deriving only the type classes you actually need, or split derivation into smaller modules.
Scala 2 Incompatibility
This feature requires Scala 3.3+. Projects on Scala 2.13 cannot use scala.deriving.Mirror without cross-building or a full migration. If you maintain a cross-built library, you'll need separate derivation implementations (e.g., shapeless 3 for Scala 2, native deriving for Scala 3).
Debugging Generated Code
When derivation produces unexpected results, inspect the generated instance. Compile with scalac -Xprint:postInlining YourFile.scala to see the synthesized given, or use javap -p YourClass$ on the compiled class files to verify the mirror-based delegation is present.
Runtime Reflection Not Required
Unlike Java reflection or Scala 2's TypeTag, Mirror is purely compile-time. There's no runtime overhead for the introspection itself—the generated code is essentially what you'd write by hand, just produced by the compiler.
Try It in a Small Project
Create a fresh Scala 3 project (sbt, Mill, or Scala CLI) and add the JsonFormat example above. Compile with scala-cli run . --scala 3.3.3 (or your preferred Scala 3.3+ version). Verify the output matches a manually written instance. Then extend the codec with a read implementation using a real JSON parser (e.g., uPickle or circe's parser) to see how far deriving carries you.
If you hit a wall—say, a recursive type or a custom annotation—remember that deriving is extensible: you can override the derived instance for specific types by providing a more specific given in the companion object. The compiler prefers explicit givens over the derived fallback.
Deriving won't replace every hand-written instance, but for the 80% of your domain model that follows regular patterns, it eliminates the drudgery without sacrificing type safety.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.