Scala reflection enables runtime introspection and manipulation of types, allowing you to analyze and generate code dynamically. Scala 2 relied on Java reflection and the Scala reflection library; Scala 3 introduces TASTy reflection, a new API that operates on type-safe abstract syntax trees captured at compile time.
Core concept
Reflection is the ability to inspect and interact with program structure at runtime. In Scala, this means querying type information, examining class members, and sometimes generating or modifying code dynamically.
Scala has evolved its reflection story significantly. Scala 2 offered two reflection paths:
- Java reflection: Using
java.lang.Classand related APIs, but losing Scala-specific information like implicit parameters, higher-kinded types, and path-dependent types. - Scala reflection: A richer API built on the mirror/symbol model that preserved Scala semantics but was slower and more complex.
Scala 3 introduces a completely redesigned approach: TASTy reflection. TASTy (Typed Abstract Syntax Tree in binarY) is Scala 3's intermediate representation that captures full type information. Reflection now operates on this tree, providing a cleaner, more powerful API than Scala 2's symbol-based approach. Unlike runtime reflection which must decode bytecode, TASTy reflection accesses pre-computed type information, making it faster and more reliable.
What erasure removes: ClassTag, TypeTag, and TypeTest
Every reflection decision starts from one JVM fact: generic type arguments do not survive compilation. At runtime List[String] and List[Int] are the same class, and case xs: List[String] tests only the head constructor - an unchecked warning, and the branch happily matches a list of ints. Scala 2 offered three recovery levels of very different cost.
ClassTag[T]carries the erasedClassobject - enough to allocate anArray[T]and cast on the head constructor, nothing more. The compiler materialises it; it costs one object and no extra dependency.TypeTag[T]carries the full Scala type - type arguments, path-dependent prefix, refinements, variance - sotypeOf[List[String]] =:= typeOf[List[Int]]is correctlyfalse. You pay by puttingscala-reflect, an artifact separate fromscala-library, on the runtime classpath.WeakTypeTag[T]tolerates unresolved type parameters - what a macro needs whileTis still abstract.
def firstOf[T](xs: List[Any])(implicit ct: ClassTag[T]): Option[T] =
xs.collectFirst { case ct(t) => t } // matches the erased class only
firstOf[List[String]](List(List(1, 2))) // Some(List(1, 2)) - erasure winsScala 3 keeps ClassTag, adds TypeTest for checkable runtime narrowing, and does not materialise TypeTag at all - full type fidelity moved to compile time in scala.quoted. A signature like def parse[T: TypeTag](s: String): T has no drop-in port; it becomes an inline def or a type class.
Mirrors and the universe abstraction
The Scala 2 runtime API confuses people for one structural reason: Symbol, Type, and Tree are not top-level classes but members of a universe. scala.reflect.runtime.universe is the runtime one; a Scala 2 macro sees a different one through its context. Two universes mean two incompatible Symbol types - hence errors between things that print identically, and the wildcard import atop every example.
A universe knows types but nothing about a running program. The bridge is a mirror, and mirrors are layered: runtimeMirror(classLoader) is scoped to a classloader, reflect(instance) narrows to one object, and reflectMethod / reflectField turn a member symbol into an invocable or readable handle. Construction goes through reflectClass then reflectConstructor.
import scala.reflect.runtime.universe._
import scala.reflect.runtime.currentMirror
val im = currentMirror.reflect(user) // InstanceMirror
val sym = typeOf[User].decl(TermName("email")).asTerm // Symbol
im.reflectField(sym).get // FieldMirror.getThe classloader argument is where deployments break. Under Spark executors, sbt's layered loaders, or a servlet container, the thread context classloader is not the one that loaded your classes, and the symptom is a missing-class error for a class demonstrably on the classpath. Prefer runtimeMirror(getClass.getClassLoader) from inside the class whose types you reflect over.
Thread safety is the second trap. Symbols complete lazily and completion mutates shared state, so concurrent typeOf calls can fail nondeterministically rather than merely contend. Serialise reflection through one lock, or force every type you need on a single thread at startup and cache it.
TASTy reflection in Scala 3
TASTy is the foundation of Scala 3's new reflection system. Every compiled Scala 3 class carries a TASTy file embedded in its bytecode, containing a complete representation of types, members, and signatures. This enables reflection without the ambiguity that Java bytecode carries.
Key concepts:
- Tree: Represents syntax — method calls, lambda expressions, class definitions. Trees are immutable and composable.
- Type: Represents the type of an expression or definition. Includes classes, traits, type parameters, and complex types like
List[String]. - Symbol: A unique identifier for a definition (class, method, field). Symbols carry semantic information like accessibility, parents, and members.
- Context: The compilation context that provides access to the quote/unquote API and reflection capabilities.
In Scala 3, most reflection happens inside quoted expressions (metaprogramming), where you have compile-time access to types and trees. The API is available via the scala.quoted package.
How it works: practical reflection patterns
Reflection in Scala typically serves framework code, serialization libraries, and metaprogramming. Here are the common patterns:
1. Type-safe serialization
A serialization framework needs to convert an object to JSON. Rather than hardcoding each type, reflection discovers fields and their types:
import scala.quoted.*
inline def toJson[T](value: T): String = ${toJsonImpl[T]('value)}
def toJsonImpl[T](value: Expr[T])(using Type[T])(using Quotes): Expr[String] = {
val fields = TypeRepr.of[T].typeSymbol.memberFields
// Iterate fields, extract values, build JSON string
'{"..."}
}
The Type[T] context provides runtime type information that was erased in Java generics. The Expr[String] is a quoted expression—code you're generating.
2. Exploring class members
Query what methods or fields a class has:
def classMethods[T](using Type[T])(using Quotes): List[String] = {
val sym = TypeRepr.of[T].typeSymbol
sym.memberMethods.map(_.name)
}
This runs inside a quoted context and extracts method names from the type's symbol tree.
3. Macro-based code generation
Macros use reflection to generate boilerplate. For example, a @derive(Encoder) annotation on a case class can automatically generate an encoder:
def deriveEncoder[T](using Type[T])(using Quotes): Expr[Encoder[T]] = {
val fields = TypeRepr.of[T].typeSymbol.caseFields
// Generate encode logic for each field
'{ /* generated encoder */ }
}
Reflection APIs and use cases
What you can inspect:
- Type structure: Is this a case class, trait, or sealed family? Does it have type parameters?
- Members: What methods, fields, and constructors does it have? What are their signatures and type bounds?
- Annotations: What compile-time annotations are present?
- Variance: Are type parameters covariant, contravariant, or invariant?
- Implicit resolution: (In macros) Can you find implicit instances that match a type?
Common use cases:
- Serialization/deserialization: JSON encoders (circe, Play JSON), protocol buffers, avro. Reflection discovers fields and types automatically.
- ORM/database mapping: Slick, Quill. Map case classes to database rows via reflection.
- Configuration: Parse config files into typed structures. Discover configuration keys from case class fields.
- Mock/test framework generation: Create mock objects or test fixtures dynamically.
- RPC/API codegen: Generate client stubs or server routes from annotated methods.
- Dependency injection: Container frameworks discover injectable types and their dependencies.
When to use reflection vs. alternatives
Reflection excels when:
- You need compile-time reflection (Scala 3 macros). TASTy gives you full type info without runtime cost.
- You're building a framework or library that serves many types. The cost is paid once at compile or startup, not per use.
- You need boilerplate elimination. Codegen via macros is often cleaner than manual derivation.
Reflection is overkill when:
- You can solve the problem with type classes. Type classes are more explicit, compose better, and avoid the dynamism of reflection.
- You're in hot code paths. Reflection, even in Scala 3, has overhead compared to direct code.
- The type structure is simple and known. Sometimes manual code is clearer than reflection magic.
Type classes as an alternative:
Many reflection use cases can be replaced by type classes. For example, serialization:
// Type-class approach (preferred when possible)
trait Encoder[T] {
def encode(value: T): String
}
object Encoder {
given Encoder[String] = _.toString
given Encoder[Int] = _.toString
given [T](using Encoder[T]): Encoder[List[T]] = list =>
"[" + list.map(summon[Encoder[T]].encode(_)).mkString(",") + "]"
}
def toJson[T](value: T)(using Encoder[T]): String =
summon[Encoder[T]].encode(value)
This is explicit, composable, and the compiler checks all the constraints at compile time. No dynamic discovery needed. For user code, prefer type classes over reflection.
Scala 2 vs. Scala 3 reflection
Scala 2 reflection:
- Symbol-based API via
scala.reflect. - Complex mirror/symbol model. Required understanding of universe semantics.
- Slower, less reliable for Scala-specific types.
- Macros relied on
scala.reflect.macros.Context, which was unstable across versions.
Scala 3 reflection (TASTy):
- Quoted expressions and the
scala.quotedAPI. - Simpler, more orthogonal. Quotes vs. splices have clear scope.
- Faster, since TASTy is pre-computed at compile time.
- Type-safe. The compiler enforces that you only inspect types you can statically know.
- Better for macro stability across versions (TASTy is a stable format).
If you're starting new code in Scala 3, use Scala 3's reflection. Scala 2 reflection is still available for compatibility but is deprecated in favor of the new approach.
Trade-offs and gotchas
Compile-time overhead: Reflection, especially via macros, increases compile time. The reflection work happens at compilation, not runtime, but it's still work.
Rare in application code: Most application code should not use reflection directly. Reflection is a framework-building tool. If you're writing business logic, you probably want type classes or regular polymorphism instead. Using reflection in application code is a code smell—it signals lost type information and makes the code harder to follow.
Macro debugging is hard: When a macro generates code incorrectly, the error message can be cryptic. You may need to use debug macros (like scala.quoted.Quotes#show) to see what code was generated. Scala 3 is better here than Scala 2, but it's still not as straightforward as regular coding.
TASTy limitations: TASTy reflects what the compiler saw. If a type is not fully visible (e.g., from a sealed external library), you can't inspect it deeply. Also, some runtime information like runtime class names of erased generics are not available in TASTy.
Performance in tight loops: If your code calls a macro or reflective method repeatedly, it's not a hot-path concern (macros are inline, so overhead is at compile time). But if you're doing runtime reflection in a tight loop, consider caching the reflection results or using a different approach.
Learning curve: Scala 3's reflection is simpler than Scala 2's, but it still requires understanding quoted expressions, type contexts, and the shape of the reflection API. Not every Scala developer needs to master this—it's specialized knowledge.
Reflection under ahead-of-time compilation
Native-image builds change the calculus. GraalVM's native-image does closed-world reachability analysis: a constructor or method reached only through a reflective lookup is invisible to the analyser and gets stripped. The failure is not a build error but a missing-method exception at runtime, in a binary whose JVM build passed every test.
The escape hatch is metadata. A reflect-config.json on the image build path lists each class, constructor, method, and field to retain. Hand-writing it for a Scala codebase is impractical, so the normal workflow is to run on a stock JVM under the tracing agent (-agentlib:native-image-agent=config-output-dir=...) and let it record what was touched - which makes native-image reflection failures a test-coverage problem.
Scala's runtime universe is a hostile citizen here: touching it loads the symbol-table machinery and completes symbols lazily, so it is both a first-request startup cost and a large dynamically-reached surface the analyser cannot follow. Compile-time derivation sidesteps this - a macro or a derives clause emits ordinary method calls that reachability analysis walks like any other code. See Scala 3 metaprogramming, contextual abstraction, and Shapeless for the three derivation routes.
Best practices
- Prefer type classes. Reflection should be used for frameworks, not business logic. Type classes are the idiomatic Scala way to handle open hierarchies.
- Use Scala 3 if you can. The quoted API is much cleaner than Scala 2's symbol-based reflection. If stuck on Scala 2, consider shapeless or other macro libraries that abstract away the complexity.
- Cache reflection results. If you're doing runtime reflection, compute once and reuse. Many serialization libraries cache codec tables.
- Keep macros small and focused. Generate one thing well. Complex multi-stage macro pipelines are hard to debug.
- Test macro-generated code. Macros are code generators, and generated code can be wrong. Verify the output.
- Document what's happening. Code relying on reflection or macros is harder to follow. Add comments explaining what the reflection does.
Conclusion
Reflection is a powerful tool for framework builders and library authors. Scala 3's TASTy-based reflection is simpler, faster, and more reliable than Scala 2's approach. However, reflection is a specialized tool—most application code should reach for type classes or explicit polymorphism instead. Use reflection when you're building infrastructure (serialization, ORM, DI containers, macro-driven codegen), but keep it out of business logic. The best code often needs the least reflection.
Reflection in Scala is two tools sharing one name. Runtime reflection buys back what erasure removed, and charges scala-reflect on the classpath, a classloader-scoped mirror that breaks under Spark and servlet containers, symbol completion that is not thread-safe, and a binary GraalVM cannot analyse without hand-written metadata. Compile-time reflection over TASTy answers the same questions with none of those costs - which is why Scala 3 kept ClassTag, dropped TypeTag, and moved the rest to scala.quoted.