Demystifying Scala 3 Macros: A Deep Dive with Quoting and Splicing
Source: Dev.to
Introduction
Welcome to the world of compile‑time metaprogramming in Scala 3! Macros may feel magical, but they are built on a powerful system of quoting and splicing. In this article we dissect a practical macro that converts any case class into a Map[String, Any]. By the end you’ll understand what the macro does, how it works, and the low‑level reflection concepts that power it.
Macro Code
package com.dev24.macros
import scala.quoted.*
object ToMapMacro {
// The user‑facing, inline method
extension [A](inline a: A) inline def toMap: Map[String, Any] = ${ toMapImpl('a) }
// The implementation that runs at compile time
def toMapImpl[A: Type](instance: Expr[A])(using Quotes): Expr[Map[String, Any]] = {
import quotes.reflect.*
// 1. Get the type and symbol of A
val tpe = TypeRepr.of[A]
val sym = tpe.typeSymbol
// 2. Get the list of case class fields
val fields: List[Symbol] =
if (sym.isClassDef) sym.caseFields.toList
else Nil
// 3. For each field, generate a quoted (String, Any) tuple
val mapEntries: List[Expr[(String, Any)]] = fields.map { field =>
val fieldNameExpr = Expr(field.name)
val fieldAccess = Select(instance.asTerm, field)
val fieldValueExpr = fieldAccess.asExprOf[Any]
'{ ($fieldNameExpr, $fieldValueExpr) }
}
// 4. Assemble the final Map from the list of tuples
'{ Map(${Varargs(mapEntries)}*) }
}
}
Part 1 – The Gateway to Metaprogramming: The inline Method
extension [A](inline a: A) inline def toMap: Map[String, Any] = ${ toMapImpl('a) }
extension [A]adds a method to any typeA, allowing calls likemyCaseClass.toMap.inline a: Atells the compiler to replace the call site with the result of the macro during compilation, rather than executing a runtime method.${ … }is the splice operator, bridging normal code to macro code. The expression inside is executed by the compiler.toMapImpl('a)invokes the macro implementation, passing a quoted representation ofa('a). The quote lifts the value into an AST (Expr[A]), not the runtime value.
When the compiler sees person.toMap, it pauses, calls toMapImpl with the quoted AST of person, receives a new quoted Map, and splices that code back into the program.
Part 2 – The Engine Room: Macro Implementation
def toMapImpl[A: Type](instance: Expr[A])(using Quotes): Expr[Map[String, Any]]
A: Typeis a context bound providing aType[A](a compile‑time type tag) so the macro can inspect the structure ofA.instance: Expr[A]is the quoted AST of the value being converted.using Quotessupplies the macro’s toolbox, granting access to reflection APIs.- The method returns a quoted
Map[String, Any](Expr[Map[String, Any]]), not an actual map.
Low‑Level Reflection
import quotes.reflect.* brings in the core reflection types:
TypeRepr– a rich internal representation of a type.Symbol– the unique identifier for any declaration (class, field, method, etc.).Term– the AST node for expressions or statements.Expr– a type‑safe wrapper around aTerm.
Step 1 – Inspecting the Type
val tpe = TypeRepr.of[A]
val sym = tpe.typeSymbol
TypeRepr.of[A]obtains the full compile‑time description ofA.tpe.typeSymbolextracts the primarySymbolfor the type (e.g., the class definition of a case class).
Step 2 – Finding the Fields
val fields: List[Symbol] =
if (sym.isClassDef) sym.caseFields.toList
else Nil
sym.isClassDefensures the symbol represents a class.sym.caseFieldsreturns the list of field symbols for a case class; otherwise, an empty list is used.
Step 3 – Generating Map Entries
val mapEntries: List[Expr[(String, Any)]] = fields.map { field =>
val fieldNameExpr = Expr(field.name)
val fieldAccess = Select(instance.asTerm, field)
val fieldValueExpr = fieldAccess.asExprOf[Any]
'{ ($fieldNameExpr, $fieldValueExpr) }
}
- For each field, we create a quoted tuple
(fieldName, fieldValue). Select(instance.asTerm, field)builds a term that accesses the field on the given instance.- The tuple is lifted into a quoted expression with
'{ … }.
Step 4 – Assembling the Final Map
'{ Map(${Varargs(mapEntries)}*) }
Varargs(mapEntries)splices the list of tuple expressions as varargs toMap.apply.- The whole expression is quoted, producing the code that will construct the map at runtime.
Conclusion
The macro works by:
- Receiving a quoted representation of a case‑class instance.
- Using compile‑time reflection to discover the class’s fields.
- Generating quoted code that builds a
Map[String, Any]from those fields. - Splicing that generated code back into the call site.
This demonstrates how Scala 3’s quoting and splicing, combined with low‑level reflection, enable powerful compile‑time code generation while keeping the user‑facing API clean and idiomatic.