Metaprogramming Overview

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-16). The metaprogramming features of Scala 3 (inline, scala.compiletime, scala.quoted and quotes.reflect) are stable since 3.0, and nothing on this page is experimental or in preview, so everything applies to the 3.3 LTS and 3.9 LTS lines. The layered example below was also run on 3.3.8 with the same output. The examples use the trailhead scenario, a toolkit that plans hikes on a network of mountain trails.

Metaprogramming means writing programs that analyze or generate other programs. Scala 3 replaced the experimental macros of Scala 2 with a system of layers. Each layer adds power and costs more to write, so the guideline is to use the first layer that solves the problem. Most code never needs to go beyond the second one.

The layers

The Reference and the Macros Guide both present the system as layers that build on each other.

Layer What it gives you Page

inline

A modifier that makes the compiler expand a value or method at each use. With inline if and inline match it decides branches at compile time.

Inline

Compile-time operations

Helpers in scala.compiletime for use inside inline methods: the value of a singleton type, the type of an argument, custom errors, summoning givens, and type-level arithmetic.

Compile-Time Operations

Macros

An inline method whose body is a splice that runs ordinary Scala code at compile time. That code builds the program to insert, as a value of type Expr[T], using quotes.

Macros: Quotes & Splices

Quoted pattern matching

Deconstructing an Expr or a Type with patterns written as quoted code.

Quoted Pattern Matching

Reflection

quotes.reflect: the typed abstract syntax tree (Term, TypeRepr, Symbol) for what quotes cannot express.

Reflection with quotes.reflect

Staging and TASTy inspection

Run-time code generation (scala.quoted.staging), and loading .tasty files to analyze their trees.

Staging & TASTy Inspection

The Reference summarizes the idea in a few words: inline guarantees expansion, compile-time operations help inside inline methods, quotation turns code into data ('{ ... } for expressions and '[ ... ] for types), splicing turns data back into code (${ ... }), reflection reveals the tree behind a quotation, and staging runs the same quote and splice machinery at run time. Macros use the quote and splice machinery at compile time and leave staging aside.

Where each layer runs

The Scala 3 compiler is a pipeline of phases. scalac -Vphases lists them for the installed version. These are the ones that matter here, in order, with the descriptions printed by the 3.10.0 compiler:

  • parser: scan and parse sources.

  • typer: type the trees.

  • pickler: generates TASTy info.

  • inlining: inline and execute macros.

  • postInlining: add mirror support for inlined code.

  • staging: check staging levels and heal staged types.

  • splicing: splicing.

  • pickleQuotes: turn quoted trees into explicit run-time data structures.

  • later phases: erasure, the other transformations and the code generator that writes .class files.

flowchart TD SRC["Source files"] --> PARSER["parser"] PARSER --> TYPER["typer"] TYPER --> PICKLER["pickler"] PICKLER --> TASTY[".tasty files: typed trees, inline bodies kept"] PICKLER --> INLINING["inlining: expands inline calls, runs macros"] INLINING --> POST["postInlining, staging, splicing, pickleQuotes"] POST --> LATER["erasure and the other later phases"] LATER --> CLASS[".class files"] L1["inline, compile-time operations"] -.->|"expanded here"| INLINING L2["macros: quotes and splices, reflection"] -.->|"macro code runs here"| INLINING L3["TASTy inspection"] -.->|"reads"| TASTY L4["staging at run time"] -.->|"uses the compiler inside the running program"| CLASS

Points the diagram makes:

  • pickler runs before inlining. The .tasty file therefore stores the trees as written, with the call twice(a) and the body of twice as separate definitions. The expansion appears only in the later phases.

  • Everything that runs inside a macro runs in the compiler’s own JVM, during inlining, while the program being compiled does not exist yet. This is why a macro implementation has to be compiled before the code that uses it (see below).

  • Staging is the only layer that is active at run time. The program carries the compiler (the scala3-staging artifact) and generates and runs code while it is running.

  • TASTy inspection is a separate tool. It does not take part in compilation: it loads a .tasty file and walks its trees.

Printing the tree after two phases shows what the inlining phase does. For inline def twice(inline x: Int): Int = x + x and def f(a: Int): Int = twice(a), run with -O -Vprint:typer,inlining (verified on 3.10.0):

[[syntax trees at end of                     typer]] // P.scala
package <empty> {
  final lazy module val P$package: P$package = new P$package()
  final module class P$package() extends Object() { this: P$package.type =>
    inline def twice(inline x: Int): Int = (x + x):Int
    def f(a: Int): Int = twice(a)
  }
}

[[syntax trees at end of                  inlining]] // P.scala
package <empty> {
  final lazy module val P$package: P$package = new P$package()
  final module class P$package() extends Object() { this: P$package.type =>
    private def writeReplace(): AnyRef =
      new scala.runtime.ModuleSerializationProxy(classOf[P$package.type])
    inline def twice(inline x: Int): Int = (x + x):Int
    def f(a: Int): Int = (a + a):Int
  }
}

The call twice(a) is still there after typer and f contains a + a after inlining. (The text of the Reference index page says that inlining is performed during the Typer phase; on 3.10.0 the tree dumps show it as a separate phase after typer.)

TASTy

TASTy (Typed Abstract Syntax Trees) is the interchange format of Scala 3. The compiler writes one .tasty file next to the .class files. The class files are what the JVM runs, and they are an incomplete description of the source because of erasure: a List[Int] becomes a List of Object and the JVM cannot know what Scala 3 features like union types and opaque types mean. The .tasty file keeps the whole typed tree after type checking, and the compiler reads TASTy, not class files, when it compiles against a Scala 3 library.

This matters for metaprogramming because of what the tree holds. An inline method has no method in its class. The body is only in TASTy, and the client’s compiler copies it from there:

//> using scala 3.10.0
package trailhead
case class Trail(name: String)
inline def twice(inline x: Int): Int = x + x

Compiling this with scala compile T.scala -d out and listing the output gives T$package.class, T$package$.class, T$package.tasty and the files for Trail. javap -p on T$package$ shows only the module field, the constructor and the serialization hook, with no twice. The compiler can decompile the TASTy file back to source:

$ scalac -decompile 'out/trailhead/T$package.tasty'
/** Decompiled from out/trailhead/T$package.tasty */
package trailhead {
  object T$package {
    inline def twice(x: scala.Int): scala.Int = (x.+(x): scala.Int)
  }
}

scalac -print-tasty prints the file section by section. For the same file it reports version: 28.10.0 and tooling: Scala 3.10.0: the TASTy format has its own version number, and a compiler refuses to read a file produced by a newer one. That is why code compiled by a newer Scala 3 can fail to load in an older compiler, and why the Scala 2.13 compiler can read the TASTy of Scala 3 libraries only up to a certain version (see Scala 2 & Scala 3 Compatibility).

A program with all four code layers

The files below define four things in the order of the layers, and use them from a separate test file. A macro cannot be used in the compilation run that defines it. In one file the call fails:

-- Error: S.scala:5:40
5 |@main def run(): Unit = println(sourceOf(1))
  |                                ^^^^^^^^^^^
  |     Cannot call macro method sourceOfImpl defined in the same source file

So the definitions go in Layers.scala and the use in a file in the test scope, which Scala CLI compiles after the main scope. A real project does the same with two modules.

//> using scala 3.10.0

package trailhead

import scala.compiletime.error
import scala.quoted.*

// layer 1: inline
inline def maxGrade: Int = 6

// layer 2: compile-time operations
inline def checkedGrade(inline g: Int): Int =
  inline if g < 1 || g > maxGrade then error("grade out of range") else g

// layer 3: a macro is an inline method whose body is a splice of a method that builds code
inline def sourceOf(inline expr: Any): String = ${ sourceOfImpl('expr) }

private def sourceOfImpl(expr: Expr[Any])(using Quotes): Expr[String] =
  import quotes.reflect.*                       // layer 4: reflection
  Expr(expr.asTerm.show)
//> using scala 3.10.0
//> using test.dep org.scalameta::munit::1.2.1

import trailhead.*

class LayersSuite extends munit.FunSuite:
  test("layers"):
    println(checkedGrade(4))
    println(sourceOf(List(1, 2).map(_ + 1)))

Run with scala test . (Scala CLI 1.17.1, which fetched munit from Maven Central):

4
scala.List.apply[scala.Int](1, 2).map[scala.Int](((_$1: scala.Int) => _$1.+(1)))
LayersSuite:
  + layers 0.003s

A call checkedGrade(9) in the same test file fails with the compile error grade out of range at the call. The sourceOf output is produced at compile time by asTerm.show, which prints the typed tree with fully qualified names; it is the same information the compiler holds, and it is not the text of the source file. Only the final string constant is in the compiled program.

Notes on the four lines that matter:

  • ${ sourceOfImpl('expr) } is the shape of every macro entry point: one splice with one call to the implementation, with the argument quoted by 'expr. Quotes is the context that gives access to the compiler. Details are on Macros: Quotes & Splices.

  • inline expr: Any makes the macro see the argument expression itself, here List(1, 2).map(_ + 1), and not a value that was already computed.

  • Expr(...) lifts a string constant into an expression (a ToExpr instance does the work).

  • import quotes.reflect.* brings the typed tree API of the reflection layer, and asTerm converts an Expr into a Term.

Choosing a layer

  • A constant or an overhead problem: inline val or inline def.

  • Compile-time checks and type-level values: inline with constValue, error and summonInline (see Compile-Time Operations). Type class derivation is built from these (see Type Class Derivation).

  • Code that depends on the structure of an argument: a macro, starting with quotes and quoted patterns. Move to the reflection API only when the quote API cannot express what is needed.

  • Code generated at run time: staging. Rare in application code.

  • Analysis of compiled code: TASTy inspection, used by tools such as documentation generators and linters.

Match types (see Match Types), opaque types and extension methods are not metaprogramming, but they solve a part of the problems that Scala 2 solved with macros, and they should be tried first.

Scala 2 macros, scala-reflect and quasiquotes

Scala 2 macros (def m(x: Int): Int = macro impl) were an experimental feature since 2.10 that was tied to the internals of the Scala 2 compiler. Scala 3 dropped them: "The previous, experimental macro system has been dropped" in the words of the Reference, and it replaces them with inline plus quotes and splices. The consequences, each checked on 3.10.0 or 2.13.18:

  • The Scala 3 compiler does not expand Scala 2.13 macros. A Scala 3 module can depend on a 2.13 artifact that has macros, and the compiler reports Scala 2 macro cannot be used in Scala 3 at the use. The flag -Xignore-scala2-macros turns this error into a warning, and the resulting class files are incomplete. The way out is a version of the library built for Scala 3, or rewriting the macro. The Migration Guide shows how to cross-build a macro library so that one source tree has a Scala 2 and a Scala 3 implementation.

  • Scala 3 never expands a macro definition, but it still accepts one so that a library can mix both lines in one artifact. With import scala.language.experimental.macros and an existing Impl.impl, Scala 3.10.0 compiles def m(x: Int): Int = macro Impl.impl next to an inline def m of the same name, and a call M.m(41) runs the Scala 3 inline version. Without the inline companion the Scala 2 definition is not callable from Scala 3 (value m is not a member of object M), and without the import the compiler reports Scala 2 macro definition needs to be enabled. This macro-mixing technique is described in Scala 2 & Scala 3 Compatibility.

  • scala-reflect and its scala.reflect.runtime.universe are a Scala 2 library. With Scala 2.13 and //> using dep org.scala-lang:scala-reflect:2.13.18, typeOf[List[Int]] printed List[Int] and the quasiquote q"1 + 2" printed 1.$plus(2). Without that dependency the package scala.reflect.runtime does not exist, even on 2.13. On Scala 3.10.0, with the same dependency added, typeOf[List[Int]] fails with No TypeTag available for List[Int], because Scala 3 does not generate Scala 2 TypeTag values. A project that needs runtime type information in Scala 3 uses ClassTag, TypeTest, Java reflection, or generates the information at compile time with a macro or with derivation.

  • Quasiquotes (q"...") are a Scala 2 feature of the same family. The Scala 3 counterpart is the quote '{ ... }, which the compiler type-checks where it is written, whereas a quasiquote builds a tree that is type-checked only after it is inserted.

For reading old code, the Scala 2 guides remain online and are marked as specific to Scala 2: the Def Macros overview and the Reflection overview.

Comparison with other languages

  • Java has annotation processing (JSR 269), which generates new source files but cannot rewrite a method body, and reflection at run time. Project Lombok and similar tools modify the compiler’s tree through internal APIs. Scala’s macros are a supported API that can inspect and replace a method call at its use.

  • Kotlin has compiler plugins and KSP (Kotlin Symbol Processing) for code generation, and inline fun for the overhead part. There is no macro system in the language, so code generators are separate artifacts.

  • Rust has declarative macros (macro_rules!) and procedural macros, which operate on token streams. Scala macros operate on typed trees, so the code is checked when it is generated and again when it is inserted.

  • C and C++ use the preprocessor, which operates on text and is the source of the problems that typed macros avoid. Templates and constexpr correspond to the first two Scala layers.

  • Scala 2 had def macros, macro annotations (a compiler plugin outside the language), quasiquotes and scala-reflect. Scala 3 has one mechanism for expression macros. Macro annotations exist only as an experimental feature: scala.annotation.MacroAnnotation is marked @experimental and the compiler rejects its use outside experimental mode (verified on 3.10.0).

Where the books are outdated

  • Scala 2 books and blog posts describe scala-reflect, c.Expr and quasiquotes as the way to write macros. None of this works on Scala 3. Use quotes and splices.

  • Material written for the first Scala 3 releases (the 3.0.0-RC period) shows slightly different scala.quoted signatures. The Macros Guide itself says that it was written against 3.0.0-RC3. Check the API pages of the version in use.

  • Posts about Mirror and derivation often use the phrase "compile-time reflection". The tool for these tasks is scala.deriving.Mirror and inline, not quotes.reflect.

  • Descriptions of inlining as a typer feature do not match the phase list of current compilers (see above).