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 |
|---|---|---|
|
A modifier that makes the compiler expand a value or method at each use. With |
|
Compile-time operations |
Helpers in |
|
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 |
|
Quoted pattern matching |
Deconstructing an |
|
Reflection |
|
|
Staging and TASTy inspection |
Run-time code generation ( |
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
.classfiles.
Points the diagram makes:
-
picklerruns beforeinlining. The.tastyfile therefore stores the trees as written, with the calltwice(a)and the body oftwiceas 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-stagingartifact) 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
.tastyfile 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.Quotesis the context that gives access to the compiler. Details are on Macros: Quotes & Splices. -
inline expr: Anymakes the macro see the argument expression itself, hereList(1, 2).map(_ + 1), and not a value that was already computed. -
Expr(...)lifts a string constant into an expression (aToExprinstance does the work). -
import quotes.reflect.*brings the typed tree API of the reflection layer, andasTermconverts anExprinto aTerm.
Choosing a layer
-
A constant or an overhead problem:
inline valorinline def. -
Compile-time checks and type-level values:
inlinewithconstValue,errorandsummonInline(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 3at the use. The flag-Xignore-scala2-macrosturns 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
macrodefinition, but it still accepts one so that a library can mix both lines in one artifact. Withimport scala.language.experimental.macrosand an existingImpl.impl, Scala 3.10.0 compilesdef m(x: Int): Int = macro Impl.implnext to aninline def mof the same name, and a callM.m(41)runs the Scala 3inlineversion. Without theinlinecompanion 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 reportsScala 2 macro definition needs to be enabled. This macro-mixing technique is described in Scala 2 & Scala 3 Compatibility. -
scala-reflectand itsscala.reflect.runtime.universeare a Scala 2 library. With Scala 2.13 and//> using dep org.scala-lang:scala-reflect:2.13.18,typeOf[List[Int]]printedList[Int]and the quasiquoteq"1 + 2"printed1.$plus(2). Without that dependency the packagescala.reflect.runtimedoes not exist, even on 2.13. On Scala 3.10.0, with the same dependency added,typeOf[List[Int]]fails withNo TypeTag available for List[Int], because Scala 3 does not generate Scala 2TypeTagvalues. A project that needs runtime type information in Scala 3 usesClassTag,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 funfor 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
constexprcorrespond 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.MacroAnnotationis marked@experimentaland 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.Exprand 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.quotedsignatures. 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
Mirrorand derivation often use the phrase "compile-time reflection". The tool for these tasks isscala.deriving.Mirrorandinline, notquotes.reflect. -
Descriptions of inlining as a typer feature do not match the phase list of current compilers (see above).