DEV Community

Depa Reddy
Depa Reddy

Posted on

Demystifying Scala 3 Macros: A Deep Dive with Quoting and Splicing

Welcome to the world of compile-time metaprogramming in Scala 3! Macros can seem like magic, but they are built on a powerful and elegant system of Quoting and Splicing. Today, we're going to dissect a practical and elegant macro that converts any case class into a Map[String, Any]. By the end of this article, you'll understand not just what this macro does, but how it works, right down to the low-level reflection concepts that power it.

Here is the code we will be exploring:

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)}*) }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's break this down piece by piece.

Part 1: The Gateway to Metaprogramming - The inline Method

extension [A](inline a: A) inline def toMap: Map[String, Any] 
= ${ toMapImpl('a) }
Enter fullscreen mode Exit fullscreen mode

This is the method the user will call. It's designed to look and feel like a regular method, but it's anything but.

  • extension [A]: This is just modern Scala syntax to add a method to any type A. It's syntactic sugar and makes the call site clean: myCaseClass.toMap.
  • inline a: A: The inline keyword is the trigger. It tells the Scala compiler, "Do not execute this method at runtime. Instead, when you see a call to this method, replace it with the result of the code on the right-hand side during compilation."
  • ${ ... }: This is the Splice operator. It's the bridge from the "normal" code world to the "macro" world. The code inside the splice is executed by the compiler as it builds your program.
  • toMapImpl('a): This is the call to our macro implementation.

    • toMapImpl: The function that will generate the code.
    • 'a: This is the Quote operator (an apostrophe). It takes the value a and lifts it into the compiler's world as a representation of the code that produced a. This is not the runtime value of a; it is an Abstract Syntax Tree (AST) node representing the expression a. We call this a quoted expression. Its type is Expr[A]

In short: When the compiler sees person.toMap, it pauses compilation, calls toMapImpl with a quoted representation of the person expression, gets back a new piece of code (a quoted Map), and splices that new code directly into the program where person.toMap used to be.

Part 2: The Engine Room **- **The Macro Implementation

def toMapImpl[A: Type](instance: Expr[A])(using Quotes)
: Expr[Map[String, Any]]
Enter fullscreen mode Exit fullscreen mode

This is where the real work happens. This function runs inside the compiler during the compilation phase.

  • A: Type: This is a Context Bound. It's a request for a Type[A] instance to be available. Type[A] is a "type tag." It's the quoted representation of the type A itself (e.g., Person, not an instance of Person). This is how the macro knows what class it's working with.
  • instance: Expr[A]: This is the quoted expression we passed in with 'a. It's the AST for the value being converted (e.g., the AST for Person("Alice", 30)).
  • (using Quotes): This is a Context Parameter. Quotes is the macro's toolbox. It provides access to all the reflection and metaprogramming utilities. It's the entry point to the compiler's internal APIs.
  • : Expr[Map[String, Any]]: This is the return type. Crucially, the macro does not return a Map. It returns a quoted expression that, when compiled and run, will produce a Map. This is the fundamental contract of a macro: generate code, don't compute runtime values.

Low-Level Reflection: import quotes.reflect.*

To do the heavy lifting of inspecting the class structure, we need to go deeper than the high-level Quotes API provides. import quotes.reflect.* gives us access to the compiler's core reflection types.

  • TypeRepr: A rich, internal representation of a type. It's more detailed than Type.
  • Symbol: The most fundamental concept. A Symbol is a unique name for any declaration in Scala: a class, a trait, an object, a method, a field, a type parameter, etc. Think of it as the ultimate "key" in the compiler's symbol table.
  • Term: A general representation of an expression or statement in the AST. An Expr is a type-safe wrapper around a Term.

Step 1: Inspecting the Type

val tpe = TypeRepr.of[A]
val sym = tpe.typeSymbol
Enter fullscreen mode Exit fullscreen mode
  • TypeRepr.of[A]: We use our Type[A] evidence to get the full, rich TypeRepr for A. Represents a type (e.g., User, String, List[Int]). It's the "what is its structure?"

This line is about getting a handle on the type itself.

Line 1: val tpe = TypeRepr.of[A]

  • [A]: This is a type parameter. The code must be inside a context where A is known, such as a generic method (def myMethod[A]: ...) or a generic class (class MyClass[A]).
  • TypeRepr: This is the central class in Scala 3's reflection API for representing types. A TypeRepr is the compiler's internal, rich description of a type. It can tell you everything about the type's structure.
  • .of[...]: This is a summoning method. It asks the compiler: "Give me your internal TypeRepr object for the type I specify here." This operation happens entirely at compile time.

What tpe contains:
If A is List[String], tpe is a TypeRepr that knows:

  • Its base type is scala.collection.immutable.List.
  • It has one type argument, which is scala.Predef.String.
  • It's a concrete, applied type (not a generic type constructor like List on its own).

If A is just Int, tpe is a TypeRepr that represents the scala.Int type.

** tpe.typeSymbol**: From the type representation, we get its primary Symbol. If A is Person, sym is the Symbol for the Person class definition itself. Symbol Represents a declaration or name (e.g., the name of a class User, a method getName, or a field age). It's the "what is it called?" and "where was it defined?".

Line 2: val sym = tpe.typeSymbol

This line is about finding the definition or declaration that created the type.

  • tpe: This is the TypeRepr we got from the first line.
  • .typeSymbol: This method is called on a TypeRepr. It navigates from the type to the symbol that declared it.
  • Symbol: A Symbol in Scala 3 reflection represents a named declaration in your source code. This could be a class, trait, object, method, val, var, or type parameter. It's the "nameplate" of the entity.

What sym contains:

If tpe represents List[String], then sym is the Symbol for the declaration of class List in the Scala library. It has forgotten about the [String] part.

If tpe represents a case class User(name: String), then sym is the Symbol for class User

Step 2: Finding the Fields

val fields: List[Symbol] =
  if (sym.isClassDef) sym.caseFields.toList
  else Nil
Enter fullscreen mode Exit fullscreen mode
  • sym.isClassDef: We check if the symbol represents a class. This is a safety measure to ensure we don't try to get fields from a type like Int or String.
  • sym.caseFields: This is a powerful helper method on a class Symbol. It returns a List[Symbol] representing the public constructor parameters of the case class. For case class Person(name: String, age: Int), this will be a list containing the symbols for name and age.

Step 3: Generating the Map Entries (The Core Loop)

This is the heart of the macro, where quoting and splicing dance together.

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) }
}
Enter fullscreen mode Exit fullscreen mode

We are iterating over each field Symbol to create a quoted tuple ("fieldName", fieldValue).

  • val fieldNameExpr = Expr(field.name):

    • field.name gives us the actual string name of the field (e.g., "name").
    • Expr(...) is a helper that quotes a value. It takes a runtime value (the string "name") and creates a quoted expression representing that value's literal code. So Expr("name") produces the AST for the string literal "name".
  • val fieldAccess = Select(instance.asTerm, field):

    • This is pure, low-level AST construction.
    • instance.asTerm: We unwrap our type-safe Expr[A] to get the raw Term AST node.
    • Select(..., ...): This is a constructor for a field access AST node. It's equivalent to writing the code instance.field. The first argument is the "qualifier" (the object we're accessing the field on), and the second is the Symbol of the field to access. This creates the AST for person.name.
  • val fieldValueExpr = fieldAccess.asExprOf[Any]

    • We take the raw Term AST for person.name and wrap it back up into a type-safe Expr. We cast it to Any because a Map[String, Any] can hold any value type.
  • { ($fieldNameExpr, $fieldValueExpr) }: This is the masterpiece.

    • The outer '{ ... } is a Quote. It means: "Do not execute the code inside me now. Instead, construct an AST representing the code inside me."
    • The inner $fieldNameExpr and $fieldValueExpr are Splices. They mean: "Take the quoted expression I have in this variable and splice it into the code I am currently quoting."
    • So, if fieldNameExpr is the AST for "name" and fieldValueExpr is the AST for person.name, this single line builds the AST for the tuple expression ("name", person.name).

The map function returns a List[Expr[(String, Any)]], which is a list of AST nodes, one for each (key, value) pair in our desired map.

Step 4: Assembling the Final Map

'{ Map(${Varargs(mapEntries)}*) }
Enter fullscreen mode Exit fullscreen mode

This is the final step, where we assemble all the tuple ASTs into a single Map AST.

  • '{ ... }: Once again, a Quote to say "generate the code for..."
  • Map(...): We're generating a call to the Map.apply factory method.
  • ${Varargs(mapEntries)}*: This is a special splice.

    • mapEntries is our List[Expr[(String, Any)]].
    • Varargs(...) is a helper that takes a list of quoted expressions and prepares them to be used as a variable argument list. It bundles them into a single Expr[Seq[T]].
    • The splice ${...} injects this generated sequence of expressions into the Map call.
    • The ***** is part of the generated code, not the macro code. It's the syntax for splatting a sequence into varargs

So, this final line generates the complete AST for: Map(("name", person.name), ("age", person.age), ...).

Putting It All Together: A Full Example

Let's trace Person("Alice", 30).toMap.

  • Call Site: The compiler sees Person("Alice", 30).toMap.
  • Inline Invocation: Because toMap is inline, it calls toMapImpl.
  • Parameters:

    • instance is the quoted code '{ Person("Alice", 30) }.
    • A is Person.
  • Inside toMapImpl:

    • fields becomes a list of the Symbols for name and age.
    • For name:
      • fieldNameExpr becomes '{ "name" }.
      • fieldAccess becomes the AST for Select(Person("Alice", 30), name_symbol).
      • fieldValueExpr becomes '{ Person("Alice", 30).name }.
      • The map produces the quoted tuple '{ ("name", Person("Alice", 30).name) }.
    • For age:
      • The map produces the quoted tuple '{ ("age", Person("Alice", 30).age) }.
  • mapEntries is now a list containing these two quoted tuples.

  • Final Code Generation: The last line '{ Map(${Varargs(mapEntries)}*) } produces the final quoted code:

  • '{ Map(("name", Person("Alice", 30).name), ("age", Person("Alice", 30).age)) }

  • Splicing Back: The compiler takes this result and splices it back into the user's code, replacing the original .toMap call.

  • Final Compiled Code: The program now effectively contains:

    Map(("name", "Alice"), ("age", 30))

The magic is complete. The boilerplate is gone, the runtime overhead is zero, and the code is perfectly type-safe.

the full source can be found in below gist:
https://gist.github.com/depareddy/06b9f12afc73de3bce71a72bee4d44eb

Top comments (0)