Staging & TASTy Inspection
|
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). Every example was compiled and run with Scala 3.10.0
through Scala CLI 1.17.1 on JDK 26 (checked on 2026-10-15). The two libraries on this page, scala3-staging and
scala3-tasty-inspector, are published on Maven Central with the same version as the compiler, so the dependency line must
name the Scala version in use. The examples used version 3.10.0 only. The 3.9 LTS and 3.3 LTS publish the same artifacts
under their own versions; I did not run them.
A macro (see Macros: Quotes & Splices) runs when the
program is compiled. Two related tools use the same Expr and quotes.reflect APIs at other moments.
-
Staging runs quotes while the program itself is running: it generates code, compiles it with the Scala compiler that is embedded in the application, and runs the result.
-
The TASTy Inspector opens compiled
.tastyfiles, or jars that contain them, and gives the program their typed trees to read withquotes.reflect.
TASTy is the serialised typed tree that the Scala 3 compiler writes next to the class files (see the TASTy overview). The examples use the trailhead scenario, a toolkit that plans hikes on a network of mountain trails.
Staging
Dependency
//> using dep org.scala-lang::scala3-staging:3.10.0
In sbt, libraryDependencies += "org.scala-lang" %% "scala3-staging" % scalaVersion.value. The library depends on
scala3-compiler_3 of the same version, so adding it puts the whole compiler on the application’s class path. That is the
price of compiling at run time, and it is the main reason not to use staging in code that only needs a lookup table.
Source: Scala 3 Reference: Staging, API
run and withQuotes
Staging has two entry points in scala.quoted.staging. Both need a given Compiler.
-
run(body)takes a function that, given aQuotes, builds anExpr[T]. It compiles that expression, runs it, and returns theTvalue. Quote'{ ... }syntax insiderunis the same as in a macro. -
withQuotes(body)gives you aQuotesto work with expressions, for example to print one, without compiling anything. It returns whatever the body returns, as long as that is not anExpr. -
Compiler.make(classLoader)builds aCompiler. The class loader is the one that the generated code is loaded with. A second form,Compiler.make(classLoader)(using Compiler.Settings.make(outDir, compilerArgs)), takes an optional output directory and a list of compiler options.
The classic example is the specialised power function. In an ordinary program power(x, 5) is a loop over the exponent.
With staging, the loop runs when the code is generated and what is compiled is only the multiplications.
//> using scala 3.10.0
//> using dep org.scala-lang::scala3-staging:3.10.0
import scala.quoted.*
import scala.quoted.staging.*
object Stage:
given Compiler = Compiler.make(Thread.currentThread.getContextClassLoader)
def powerCode(x: Expr[Double], n: Int)(using Quotes): Expr[Double] =
if n == 0 then '{ 1.0 }
else if n % 2 == 0 then '{ val y = $x * $x; ${ powerCode('y, n / 2) } }
else '{ $x * ${ powerCode(x, n - 1) } }
def specialised(n: Int)(using Quotes): Expr[Double => Double] =
'{ (x: Double) => ${ powerCode('x, n) } }
@main def stageDemo(): Unit =
val value: Double = staging.run(powerCode('{ 1.5 }, 5))
println(value)
println(staging.withQuotes(specialised(5).show))
val cube: Double => Double = staging.run(specialised(3))
println(cube(3.0))
7.59375
((x: scala.Double) => x.*({
val y: scala.Double = x.*(x)
val `y₂`: scala.Double = y.*(y)
`y₂`.*(1.0)
}))
27.0
powerCode is the same function as in the macro example of the previous page. Here it is called at run time. The first call
compiled and ran 1.5 to the fifth power. The second printed the generated function for n = 5 with withQuotes: a
lambda with two nested vals and a multiplication, no loop. The third compiled the function for n = 3
and returned it as a normal Double => Double that the program then called with 3.0. The staged function can be kept
and called as many times as needed, so the compile cost is paid once.
The call is staging.run, not plain run. scala.quoted.* exports the package name staging, and run alone would
clash with an @main def run() or with any other method called run in scope. The first version of the example named its
@main method run and the compiler answered too many arguments for method run: (): Unit.
Source: Scala 3 Reference: Staging
The cost
Starting the embedded compiler is not free. The next program measures the first and a second run on one machine (an
Apple M-series laptop with JDK 26; the numbers are an illustration, not a benchmark):
first run (compiler warm-up): 824 ms
second run: 15 ms
(42,43)
The first run pays for loading the compiler classes and for JIT warm-up of the compiler, and later runs are much cheaper.
Even so, 15 ms is far more than the call it replaces. Staging pays off only when the generated code runs many times, or
is much faster than the generic code, as with an unrolled inner loop, a specialised matrix kernel or a query compiled to
bytecode. For a one-off value, an ordinary function is better. The same program set the options of the embedded compiler with
Compiler.Settings.make(None, List("-explain")), which is how to pass -explain, -Xcheck-macros or classpath options.
Scope extrusion
Every Expr belongs to the Quotes that created it. In a macro that is the splice, and in staging it is the run or
withQuotes call. Using an Expr outside that scope is a run-time error in staging, since there is no compile-time
check on it:
val code: Expr[Int] = staging.withQuotes('{ 40 + 2 })
println(staging.run(code))
Exception in thread "main" scala.quoted.runtime.impl.ScopeException: Cannot use Expr oustide of the macro splice `${...}` or the scala.quoted.staging.run(...)` where it was defined
at scala.quoted.runtime.impl.ScopeException$.checkInCorrectScope(ScopeException.scala:11)
at dotty.tools.dotc.quoted.PickledQuotes$.quotedExprToTree(PickledQuotes.scala:40)
...
Source: Scala 3 Reference: Staging, API
(The message is spelled oustide in the library. The stack trace is shortened here.) The fix is to build the expression
and run it inside one run, or to build it in a method that takes (using Quotes) and is called from run, as
specialised(n) was above. A value that has to cross scopes must become a plain value first: run returns a T, not
an Expr, which is why withQuotes also refuses to return one.
Two more things to keep in mind. The generated code is compiled by the embedded compiler, so it can only refer to classes that
the class loader passed to Compiler.make can see. And the generator and the generated code run in the same JVM.
Splitting a program into a generator and a specialised runtime part is the purpose of staging, while inline macros are the
better tool when all the information is known at compile time.
Staging versus macros versus runtime reflection
| Tool | When it runs | Typical use |
|---|---|---|
|
During compilation of the user’s program |
Checks, specialisation and boilerplate whose inputs are compile-time constants |
Staging |
While the program runs |
Specialising on run-time data (a configuration, a parsed query, matrix dimensions) and compiling the specialised code once |
|
While the program runs |
Dynamic calls, without new code generation |
In Java the closest alternatives are the javax.tools.JavaCompiler API, which compiles source text (untyped strings, no
quotes), and bytecode libraries such as ASM or ByteBuddy. Kotlin has kotlin-scripting for evaluating script text. Scala
staging differs in that the program is a typed Expr, so type errors in the generator are compile errors of the generator and
not failures of the generated code.
TASTy Inspector
Dependency
//> using dep org.scala-lang::scala3-tasty-inspector:3.10.0
In sbt, "org.scala-lang" %% "scala3-tasty-inspector" % scalaVersion.value. Like staging it depends on the compiler.
Source: Scala 3 Reference: TASTy Inspect
The Inspector API
The package scala.tasty.inspector has three pieces.
-
Inspectoris a trait with one method,def inspect(using Quotes)(tastys: List[Tasty[quotes.type]]): Unit. You implement it. -
Tasty[Q]is one.tastyfile as seen from the quotesQ. It haspathandast, the root tree (aPackageClause). -
TastyInspectorhas the entry points:-
inspectTastyFiles(tastyFiles: List[String])(inspector)for.tastyfiles on disk. -
inspectTastyFilesInJar(jar: String)(inspector)for all the.tastyfiles of a jar. -
inspectAllTastyFiles(tastyFiles, jars, dependenciesClasspath)(inspector)for both, plus the class path needed to resolve the types that the files mention.
-
Each entry point returns a Boolean that tells whether the inspection ran without errors. Inside inspect you import
quotes.reflect.* and use the same API as in a macro (see
Reflection with quotes.reflect). The difference is
that the trees come from files, not from the program being compiled.
The example has two parts: a library to inspect, and an inspector program. First the library, compiled to a directory with
scala compile --server=false target/Trails.scala -d out, which writes .tasty files next to the class files:
//> using scala 3.10.0
package trailhead
enum Difficulty:
case Easy, Hard
case class Trail(name: String, lengthKm: Double, difficulty: Difficulty):
def isLong: Boolean = lengthKm > 10
private def trailNote: String = "n/a"
trait Hut:
def beds: Int
The compile produced out/trailhead/Trail.tasty, out/trailhead/Hut.tasty and out/trailhead/Difficulty.tasty. The
inspector lists the classes and their methods:
//> using scala 3.10.0
//> using dep org.scala-lang::scala3-tasty-inspector:3.10.0
import scala.quoted.*
import scala.tasty.inspector.*
class ListDefs extends Inspector:
def inspect(using Quotes)(tastys: List[Tasty[quotes.type]]): Unit =
import quotes.reflect.*
for tasty <- tastys do
val tree = tasty.ast
println(s"== ${tasty.path}")
object walker extends TreeTraverser:
override def traverseTree(t: Tree)(owner: Symbol): Unit =
t match
case d: ClassDef =>
val kind = if d.symbol.flags.is(Flags.Trait) then "trait" else if d.symbol.flags.is(Flags.Enum) then "enum" else "class"
println(s"$kind ${d.symbol.fullName}")
case d: DefDef if !d.symbol.flags.is(Flags.Synthetic) && d.name != "<init>" =>
println(s" def ${d.name}: ${d.returnTpt.tpe.show}")
case _ =>
super.traverseTree(t)(owner)
walker.traverseTree(tree)(tree.symbol)
@main def inspectDemo(tasty: String*): Unit =
TastyInspector.inspectTastyFiles(tasty.toList)(new ListDefs)
Run with scala run --server=false inspector/Inspect.scala — out/trailhead/Trail.tasty out/trailhead/Hut.tasty:
== out/trailhead/Trail.tasty
class trailhead.Trail
def isLong: scala.Boolean
def trailNote: scala.Predef.String
class trailhead.Trail$
== out/trailhead/Hut.tasty
trait trailhead.Hut
def beds: scala.Int
The inspector walked the trees of the compiled library without loading any of its classes. It sees the private method
trailNote too, because it reads the full typed tree. Trail.tasty also contains the companion module class Trail$, which
the compiler generates for the case class.
The TreeTraverser is the visitor class of the API. traverseTree(tree)(owner) is called for every tree, and calling
super.traverseTree continues into the children. A ClassDef carries the symbol with fullName and flags, and a
DefDef carries the method name and the returnTpt, the tree of the declared result type.
Jars
A jar is inspected with inspectTastyFilesInJar. With the directory above packed by jar cf trails.jar -C out ., the second
inspector prints the classes defined in each file:
//> using scala 3.10.0
//> using dep org.scala-lang::scala3-tasty-inspector:3.10.0
import scala.quoted.*
import scala.tasty.inspector.*
class CountClasses extends Inspector:
def inspect(using Quotes)(tastys: List[Tasty[quotes.type]]): Unit =
import quotes.reflect.*
for tasty <- tastys do
tasty.ast match
case pkg: PackageClause =>
val names = pkg.stats.collect { case c: ClassDef => c.name }
println(s"${tasty.path}: ${names.mkString(", ")}")
case _ =>
@main def jarDemo(jar: String): Unit =
TastyInspector.inspectTastyFilesInJar(jar)(new CountClasses)
trailhead/Trail.tasty: Trail, Trail$
trailhead/Hut.tasty: Hut
trailhead/Difficulty.tasty: Difficulty, Difficulty$
Typical uses of the inspector are documentation tools, linters and API checkers that
compare two versions of a library, code generators that read case classes from a compiled module, and teaching tools that
print the typed tree of a program. A tool that needs the dependencies' types too passes their jars as the class path in
inspectAllTastyFiles. Not run here: inspectAllTastyFiles, which was only checked by reading its signature on the
published jar.
Scala 2 and Java comparison
Scala 2 has no TASTy. Tools read class files and the ScalaSignature pickle with scala.tools.nsc or the
scala-reflect library, which is tied to the compiler version. TASTy is a stable, versioned format for typed trees, and
the inspector is the supported way to read it. Java tools read class files with ASM or the JDK’s ClassFile API (JEP 484,
final in JDK 24), which sees bytecode after erasure, and annotation processors see source trees during compilation, but there
is no typed-tree format for Java comparable to TASTy. Runtime code generation in Scala 2 used scala.tools.reflect.ToolBox.
Staging is the typed Scala 3 replacement, within the limits described above.
Scala 3.9 LTS versus 3.10
-
scala3-stagingandscala3-tasty-inspectorare published for every Scala 3 version with the version number of the compiler. Use the version of the project’sscalaVersion, never a different one, because they read and write the compiler’s internal data. -
The APIs shown (
run,withQuotes,Compiler.make,Compiler.Settings.make,Inspector,Tasty,TastyInspector.inspectTastyFiles,inspectTastyFilesInJar) were run on 3.10.0 only.
Where the books are outdated
-
Scala 2
ToolBoxexamples (scala.tools.reflect) evaluate untyped source text. The Scala 3 counterpart is staging with typed quotes, and the TASTy inspector replaces ad-hoc reading of class-file pickles. -
Examples that run staging in the REPL or in a script without the compiler on the class path fail. Add the
scala3-stagingdependency (it bringsscala3-compiler_3), as the//> using depline above does. -
Older macro material says that a macro and the code that uses it need separate projects. Scala 3 allows them in separate files of one project, but staging and the inspector are always libraries that you depend on.