SLC: Add Mirror-based derivation for ToExpr and FromExpr

Deriving ToExpr and FromExpr via Mirror

1. Motivation

Every other instance in ToExpr/FromExpr is either a primitive or hand-written for a specific stdlib shape (Option, List, Either, the TupleN family). A user’s own case classes and enums get none of that: anyone writing a macro that lifts or matches their own product or sum type has to write the ToExpr/FromExpr instance by hand, field by field, case by case — exactly the kind of boilerplate scala.deriving.Mirror exists to eliminate for other typeclasses.

This proposes closing that gap:

case class Point(x: Int, y: Int) derives ToExprFactory, FromExprFactory

// ... inside a macro:
given Quotes = ...

val lifted: Expr[Point] = Expr(Point(1, 2))
val Point(x, y) = summon[FromExpr[Point]].unapply(lifted).get

ToExpr/FromExpr themselves are untouched — summon[FromExpr[Point]] above still resolves to the ordinary trait. The new pieces are a level of indirection underneath.

2. Proposed API

trait ToExprFactory[T]:
  def apply()(using Type[T]): ToExpr[T]

object ToExprFactory:
  inline def derived[T: Mirror.Of as m]: ToExprFactory[T]

trait FromExprFactory[T]:
  def apply()(using Type[T]): FromExpr[T]

object FromExprFactory:
  inline def derived[T: Mirror.Of as m]: FromExprFactory[T]

// in ToExpr / FromExpr, at low priority:
given [T: Type] => (f: ToExprFactory[T]) => ToExpr[T] = f.apply()
given [T: Type] => (f: FromExprFactory[T]) => FromExpr[T] = f.apply()

A Factory doesn’t hold a ToExpr/FromExpr directly — it holds a recipe for producing one once a Type[T] is available. The bridge given is what makes this invisible at the use site: summoning a plain ToExpr[Point]/FromExpr[Point] finds the Factory instance and immediately applies it, given Type[Point] is available there (which it always is, at any real call site).

derives ToExprFactory, FromExprFactory is the supported spelling; deriving ToExpr/FromExpr directly (ToExpr.derived/FromExpr.derived) does not exist as an API — see §3 for why the indirection is load-bearing, not incidental.

3. Design

Why a factory, not a direct derived

An earlier version of this proposal added ToExpr.derived/FromExpr.derived directly, matching every other Mirror-based derived in the standard library. It works for concrete, non-generic types with only primitive-shaped fields, but fails in two common cases:

Generic type parameters. derives mechanically adds a bound matching the typeclass being derived for each of the type’s own parameters — derives ToExpr on Box[A] produces given [A: ToExpr] => ToExpr[Box[A]] = ToExpr.derived. It does not add Type[A], because derives has no way to know that ToExpr specifically also needs type-level reification, not just the value-level typeclass. Reifying Expr[Box[A]] inside derived’s body needs Type[Box[A]], which needs Type[A] — absent from that signature, so the given never compiles.

Quotes needed eagerly, even for fully concrete types. case class Tagged(tags: List[Int]) has no type parameters at all, yet derives ToExpr still fails on it: the only ToExpr[List[Int]] is the generic ListToExpr[T: Type: ToExpr], which needs Type[Int]. Synthesizing any Type[X] — even for an ordinary, concrete X like Int — goes through Type.of[X], which unconditionally needs a Quotes instance to run: a Type[T] is a macro-time-only handle, not something that exists independent of an active expansion. A plain top-level given ToExpr[Tagged] = ToExpr.derived has no Quotes anywhere in its signature to supply that, so it fails exactly the same way, for a reason that has nothing to do with genericity.

ToExprFactory/FromExprFactory sidestep both: apply()(using Type[T]) defers the Type[T] requirement from derivation time (too early — A/Int aren’t reifiable yet) to first real use (always fine — by the time anyone summons a ToExpr[Box[Int]], Type[Box[Int]] and a live Quotes both already exist, since summoning one only happens from inside a macro).

Bridging concrete-typed instances for free

Any type that already has a plain ToExpr/FromExpr — every primitive, and every hand-written stdlib instance — gets a ToExprFactory/FromExprFactory for free via a blanket bridge:

given [T] => (te: ToExpr[T]) => ToExprFactory[T]:
  def apply()(using Type[T]): ToExpr[T] = te

So a generic type’s fields of ordinary types (Int, String, a nested derives ToExprFactory type) resolve without the user writing anything extra — only genuinely new container types would need their own Factory instance (see §5).

A sum’s own cases are derived automatically

Only the outermost type of a sealed hierarchy needs derives ToExprFactory, FromExprFactory; the individual cases don’t:

sealed trait Shape derives ToExprFactory, FromExprFactory
object Shape:
  case class Circle(r: Double) extends Shape       // no `derives` needed
  case class Rect(w: Double, h: Double) extends Shape
  case object Origin extends Shape

For each case, derived tries an ordinary summon first and only falls back to deriving it on the spot if nothing is already in scope. This recurses through as many levels of nested sums as needed, so the top-level derives is genuinely the only one required. It intentionally does not extend to a product’s own fields — a field’s type is the type being derived’s dependency, not one of its own cases, and still needs its own derives/instance.

Recursive sums work too

A case whose own field type is the enclosing sum:

enum Arith derives ToExprFactory, FromExprFactory:
  case Lit(n: Int)
  case Add(l: Arith, r: Arith)
  case Neg(e: Arith)

Computing a sum’s elemInstances eagerly, at the call site, deadlocks for this shape: deriving Arith’s own top-level given requires fully constructing Add’s instance first, which itself needs Arith’s (not yet finished) top-level given — a circular value dependency that Scala’s thread-safe lazy-val initialization can’t resolve on the same thread (confirmed via a thread dump). The fix: elemInstances is a by-name parameter to derivedProduct/derivedSum, cached in a lazy val inside the returned instance rather than evaluated at the call site — deferring construction until the first real unapply/apply call, by which point every top-level given has already finished initializing.

4. Alternatives considered

Documented in detail because the failure modes are specific and easy to rediscover independently — each of these looks reasonable until it’s actually run.

Plain ToExpr.derived[T] / FromExpr.derived[T], no factory — Superseded

The original shape of this proposal, matching every other Mirror-based derived in the standard library. Works for concrete types with only primitive-shaped fields; fails for generic type parameters and for any concrete type with a field whose own instance needs Type[_] (i.e. most stdlib containers), for the reasons in §3. Superseded by the factory indirection rather than fixed in place, since the underlying limitation (derives can’t add a Type[_] bound) isn’t something the derived body itself can work around.

Draft PR is here: SLC: Add Mirror-based derivation for ToExpr and FromExpr by halotukozak · Pull Request #26541 · scala/scala3 · GitHub

3 Likes

I believe that for completeness there should be existing solutions listed:

  1. Kit Langton’s Quotidian:
    1. quotidian/modules/core/src/main/scala/quotidian/DeriveFromExpr.scala at main · kitlangton/quotidian · GitHub
    2. quotidian/modules/core/src/main/scala/quotidian/DeriveToExpr.scala at main · kitlangton/quotidian · GitHub
  2. Hearth’s solution that combines recursive AST traversal and runtime reflection:
    1. Basic Utilities - Hearth documentation

is really limited (like my initial proposal), it doesn’t support generics, for instance

Still, stating a prior art and how the proposal innovates is just a good taste.

1 Like