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 .tasty files, or jars that contain them, and gives the program their typed trees to read with quotes.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.

run and withQuotes

Staging has two entry points in scala.quoted.staging. Both need a given Compiler.

  • run(body) takes a function that, given a Quotes, builds an Expr[T]. It compiles that expression, runs it, and returns the T value. Quote '{ ... } syntax inside run is the same as in a macro.

  • withQuotes(body) gives you a Quotes to work with expressions, for example to print one, without compiling anything. It returns whatever the body returns, as long as that is not an Expr.

  • Compiler.make(classLoader) builds a Compiler. 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.

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)
	...

(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

inline and macros

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

java.lang.reflect, MethodHandle

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.

The Inspector API

The package scala.tasty.inspector has three pieces.

  • Inspector is a trait with one method, def inspect(using Quotes)(tastys: List[Tasty[quotes.type]]): Unit. You implement it.

  • Tasty[Q] is one .tasty file as seen from the quotes Q. It has path and ast, the root tree (a PackageClause).

  • TastyInspector has the entry points:

    • inspectTastyFiles(tastyFiles: List[String])(inspector) for .tasty files on disk.

    • inspectTastyFilesInJar(jar: String)(inspector) for all the .tasty files 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-staging and scala3-tasty-inspector are published for every Scala 3 version with the version number of the compiler. Use the version of the project’s scalaVersion, 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 ToolBox examples (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-staging dependency (it brings scala3-compiler_3), as the //> using dep line 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.