Metaprogramming means writing code that produces or inspects other code. In Scala it is not an exotic corner: every time you write derives Codec, use a logging library that captures the source line, or get a compile error from a SQL string interpolator, someone else's metaprogram ran inside your compiler. Sooner or later you will write one yourself.

The Scala 3 macros article explains the design of the toolkit and the rule of reaching for the least powerful tool first. This article is the hands-on companion. It shows where each tool runs in the compiler, builds a complete type class derivation using only inline, scala.compiletime and Mirror, adds compile-time validation, and then covers the parts tutorials skip: how to test code whose failures are compile errors, how to debug it, what it costs in build time, and which tool to use when derivation is not enough. The code targets Scala 3; Scala 2 def macros are covered only as a migration concern.

Advertisement

Where metaprograms run

The Scala 3 compiler is a pipeline of phases. The parser builds trees, the typer assigns types and resolves givens, and later phases erase types and emit bytecode. Most metaprogramming happens in a window near the typer: an inline method is expanded at each call site, and while it expands the compiler can evaluate inline if and inline match, look up givens with summonInline, read constant types with constValue, and run quoted macros that build new typed trees. By bytecode time only ordinary code remains.

First, the cost of metaprogramming is paid in build time, not run time, and it is paid again every time a call site is recompiled. Second, a metaprogram only sees what the typer knows: static types, constant values and the shape of case classes and enums.

Sourcecase class, derivesParser + typertrees, types, givensInlining phaseinline defs expandLater phaseserasure, bytecodescala.compiletimeerasedValue, constValuescala.derivingMirror.Of[T]Quoted macrosExpr, Type, reflectused bySource-level toolsscalafix rewrites, sbt source generatorsrewrite / generateCompiler pluginsextra phasesTASTy inspectorreads .tastyafter compileStaging (run time)scala.quoted.staging compiles at run timeMost application needs stop at the yellow boxes. Red is for library authors; blue sits outside the typer.Everything left of the later phases has finished before your program runs, so its cost is paid in build time.
Where each metaprogramming tool acts. Inline expansion, compile-time operations, Mirrors and quoted macros all run inside the compiler near the typer; source tools act before it, plugins alongside it, and the TASTy inspector after it.

The building blocks

A derivation needs three facilities, all in the standard library. Mirrors. For every case class, enum and sealed hierarchy the compiler can synthesize a given Mirror.Of[T]. A Mirror.ProductOf[T] exposes the field types as a tuple type MirroredElemTypes, the field names as a tuple of string literal types MirroredElemLabels, the type's name as MirroredLabel, and a fromProduct constructor. A Mirror.SumOf[T] exposes the cases as MirroredElemTypes and an ordinal(x) method that tells you which case a value is.

Compile-time operations. scala.compiletime turns those types into values. erasedValue[T] pretends to produce a value of type T so you can pattern match on its type inside inline match; it is never evaluated. constValue[T] turns a literal type such as "x" into the value "x". summonInline[T] and summonFrom look up givens at the expansion site rather than the definition site. error(msg) aborts compilation with your message, and codeOf(x) renders an argument's source for that message.

Inline recursion. Tuple types are nested pairs, A *: B *: EmptyTuple, so walking them is ordinary recursion that the inliner unrolls: match the head, emit something, recurse on the tail. The compiler limits successive inlines to 32 by default, which matters for very wide case classes and is adjustable with -Xmax-inlines.

Advertisement

Worked example: deriving Show

The goal is a Show type class that renders case classes as Point(x = 1, y = 2) and handles enums by delegating to the right case, with an error message of our own when a field has no instance. The structure follows the Eq example in the Scala 3 reference. Read derived first: it inspects the mirror with inline match, so the product branch and the sum branch are chosen at compile time and only one survives in the output.

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

trait Show[A]:
  def show(a: A): String

object Show:
  given Show[Int]     = _.toString
  given Show[Boolean] = _.toString
  given Show[String]  = s => "\"" + s + "\""
  given [A](using s: Show[A]): Show[List[A]] = xs => xs.map(s.show).mkString("[", ", ", "]")

  // Field names: walk the label tuple at compile time, one constant per element.
  inline def labels[L <: Tuple]: List[String] =
    inline erasedValue[L] match
      case _: EmptyTuple => Nil
      case _: (h *: t)   => constValue[h].toString :: labels[t]

  // Field instances: look each one up, and fail with our own message if absent.
  inline def fieldShows[E <: Tuple]: List[Show[Any]] =
    inline erasedValue[E] match
      case _: EmptyTuple => Nil
      case _: (h *: t)   => fieldShow[h].asInstanceOf[Show[Any]] :: fieldShows[t]

  inline def fieldShow[H]: Show[H] =
    summonFrom {
      case found: Show[H] => found
      case _ => error("Show derivation: a field type has no Show instance")
    }

  // Enum and sealed-trait cases: use an existing instance or derive one.
  inline def caseShows[E <: Tuple]: List[Show[Any]] =
    inline erasedValue[E] match
      case _: EmptyTuple => Nil
      case _: (h *: t)   => caseShow[h].asInstanceOf[Show[Any]] :: caseShows[t]

  inline def caseShow[H]: Show[H] =
    summonFrom {
      case found: Show[H] => found
      case _ => derived[H](using summonInline[Mirror.Of[H]])
    }

  inline def derived[A](using m: Mirror.Of[A]): Show[A] =
    inline m match
      case p: Mirror.ProductOf[A] =>
        val name   = constValue[p.MirroredLabel]
        val names  = labels[p.MirroredElemLabels]
        val shows  = fieldShows[p.MirroredElemTypes]
        new Show[A]:
          def show(a: A): String =
            val values = a.asInstanceOf[Product].productIterator.toList
            names.lazyZip(values).lazyZip(shows)
              .map((n, v, s) => n + " = " + s.show(v))
              .mkString(name + "(", ", ", ")")
      case s: Mirror.SumOf[A] =>
        val shows = caseShows[s.MirroredElemTypes]
        new Show[A]:
          def show(a: A): String = shows(s.ordinal(a)).show(a)

Three details deserve attention. The helpers return List[Show[Any]] and cast, because a heterogeneous list of instances cannot be typed precisely without much more machinery; the cast is safe because each instance lines up with the value in the same position. The lookups live in small helpers with a type parameter, fieldShow[H] and caseShow[H], because a lower-case name inside a summonFrom pattern would bind a fresh type variable rather than refer to the tuple element. caseShows derives a missing case instance on the spot, which lets enum cases work without their own derives clause, but it would loop on a directly recursive type; the reference example guards that case with an explicit recursion check, and production libraries do the same.

case class Point(x: Int, y: Int) derives Show

enum Shape derives Show:
  case Circle(r: Int)
  case Poly(points: List[Point])

@main def demo(): Unit =
  println(summon[Show[Shape]].show(Shape.Poly(List(Point(0, 0), Point(3, 4)))))
  // Poly(points = [Point(x = 0, y = 0), Point(x = 3, y = 4)])

The derives Show clause asks the compiler to put a given in the companion of Point whose body is Show.derived. That placement matters: the expansion happens once per type, in one file, and every use site simply finds the given. The alternative, an inline given that derives on demand at each use site, re-expands the same code everywhere and is a common cause of slow builds and large class files.

Compile-time validation

Derivation removes boilerplate; the other everyday use is rejecting bad programs early. When an argument is a literal, an inline parameter keeps it as a constant during expansion, so an inline if can test it and error can stop the build with a precise message.

import scala.compiletime.{codeOf, error}

inline def port(inline n: Int): Int =
  inline if n < 1 || n > 65535 then error("port out of range: " + codeOf(n))
  else n

val ok  = port(8080)      // compiles to the constant 8080
// val bad = port(70000)  // compile error: port out of range: 70000
// port(readInt())        // compile error: cannot reduce inline if (not a constant)

The third line shows the limit. If the argument is not a constant the compiler cannot decide the condition and reports that it cannot reduce the inline if. That is the correct behaviour, but it surprises people, so design such APIs with two entry points: a checked inline one for literals and an ordinary one that validates at run time and returns an Either. Opaque types pair well with it: the inline constructor validates a literal and returns an opaque value that the rest of the program can trust.

Testing code that fails at compile time

A derivation has two kinds of behaviour to test: what the generated instance does, and which programs it rejects. The first is ordinary unit testing. The second needs scala.compiletime.testing: typeChecks(code) returns whether a string literal of code compiles in the current scope, and typeCheckErrors(code) returns the list of errors with messages and positions. The string must be a literal because it is compiled while your test is compiled.

import scala.compiletime.testing.typeCheckErrors

class ShowSuite extends munit.FunSuite {
  test("derives a readable encoding") {
    assertEquals(summon[Show[Point]].show(Point(1, 2)), "Point(x = 1, y = 2)")
  }
  test("rejects a field type without an instance") {
    val errs = typeCheckErrors("final class Opaque; case class Bad(o: Opaque) derives Show")
    assert(errs.exists(_.message.contains("has no Show instance")), errs)
  }
}

Assert on a distinctive fragment of your own message rather than the whole compiler message, which can change between compiler versions. Run the suite on every Scala version you publish for.

Debugging and build flags

When an expansion misbehaves, first look at what was generated. The compiler can print trees after any phase; run it with -Xshow-phases to list the phase names for your version rather than guessing them, then print after the phase that follows inlining. -Xcheck-macros adds consistency checks to macro-generated trees and catches mistakes that would otherwise surface as confusing errors in later phases.

// build.sbt: flags that help while writing inline and macro code
scalacOptions ++= Seq(
  "-Xcheck-macros",     // extra consistency checks on macro-generated trees
  "-Xmax-inlines", "64" // raise the successive-inline limit (default 32) for deep types
)
// scalac -Xshow-phases lists phase names if you want to print trees after one

Common failures map to a few causes. No given instance of type Mirror.Of means the type is not a case class, enum or sealed hierarchy of them, or a case is not accessible. Maximal number of successive inlines exceeded means a very wide or deeply nested type; raise the limit or restructure the recursion so it inlines less. An error that appears at the use site rather than in your library usually means an inline method captured something that only makes sense where it was defined; the fix is to move work into ordinary helper methods and keep the inline part thin.

What it costs and where to put it

Every inline expansion is compile work, and generated code is bytecode. A derivation that expands a few hundred lines for each of two hundred types adds noticeably to a clean build and to incremental builds that touch those companions. Three habits keep the cost bounded. Derive once per type in the companion with derives, not at use sites. Keep generated code small by delegating to ordinary runtime helpers, as the example does with mkString and the instance list, because large generated methods also defeat the JIT's inlining heuristics. And put heavily derived model types in their own sbt module so incremental compilation does not re-expand them when unrelated code changes.

There is also a people cost. Inline and macro code is harder to review, and fewer engineers can maintain it. Given resolution rules, covered in the implicit resolution article, interact with summonInline in ways that surprise even experienced users. Put metaprograms in one small, well-tested module with its own documentation rather than scattering inline tricks through application code.

The rest of the toolkit

ToolRunsUse it forWatch out for
Inline + compiletime + MirrorDuring typingType class derivation, literal validation, compile-time branchingOnly sees static types and constants
Quoted macrosDuring inliningInspecting arbitrary expressions, generating specialized codeHarder to write; reflect API is large
MacroAnnotationDuring compilationAdding definitions to annotated classesExperimental; needs experimental opt-in
Compiler pluginsOwn phasesWhole-program checks, custom lintsTied to compiler internals; Scala 3 restricts where standard plugins may run
TASTy inspectorAfter compilationTools that read typed trees of compiled code: docs, analysisOffline only; does not change the program
StagingAt run timeGenerating code specialized to run-time valuesShips a compiler with the app; slow first run
scalafix / sbt source generatorsBefore compilationCode migrations, generating sources from schemasGenerated files must be kept out of manual edits

Scala 2 def macros are not supported by the Scala 3 compiler, so a library that used them needs a Scala 3 reimplementation, usually with Mirrors plus a small quoted macro. Many existed only because Scala 2 lacked Mirrors and inline. The givens article covers the given and using machinery that every derivation relies on.

Trade-offs

  • Derivation versus handwritten instances. Derivation never drifts from the type and costs build time; handwritten instances compile fast and allow custom formats.
  • Mirror derivation versus a quoted macro. Mirror code is plain Scala and easier to maintain; a macro can produce tighter code, for example direct field access instead of a product iterator, and better error messages. Start with Mirrors and move to a macro only when profiling or error quality demands it.

What to do next

  1. Copy the Show derivation into a scratch project and derive it for a case class, an enum and a nested list; read the expansion with the phase printer.
  2. Add typeCheckErrors tests for a missing field instance and a plain class without a mirror.
  3. Write one inline constructor with error and codeOf for a value your code base validates today at run time, paired with an ordinary validating constructor.
  4. Time a clean build of your most derivation-heavy module, and move derivations to companions with derives if any are expanded at use sites.
  5. Read the macros architecture article before writing your first quoted macro, and keep all metaprogramming in one module with its own tests.
Key takeaway: Scala 3 metaprogramming is mostly ordinary code evaluated by the compiler: Mirrors describe your types, scala.compiletime turns those descriptions into values during inline expansion, and error lets you reject bad programs with your own message. Build derivations once per type in the companion, keep the generated part thin, test rejections with typeCheckErrors, read expansions when debugging, and move to quoted macros, plugins or staging only when a simpler tool demonstrably cannot do the job.