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

Published: (December 7, 2025 at 01:48 PM EST)
4 min read
Source: Dev.to

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 type A, allowing calls like myCaseClass.toMap.
  • inline a: A tells 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 of a ('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: Type is a context bound providing a Type[A] (a compile‑time type tag) so the macro can inspect the structure of A.
  • instance: Expr[A] is the quoted AST of the value being converted.
  • using Quotes supplies 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 a Term.

Step 1 – Inspecting the Type

val tpe = TypeRepr.of[A]
val sym = tpe.typeSymbol
  • TypeRepr.of[A] obtains the full compile‑time description of A.
  • tpe.typeSymbol extracts the primary Symbol for 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.isClassDef ensures the symbol represents a class.
  • sym.caseFields returns 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 to Map.apply.
  • The whole expression is quoted, producing the code that will construct the map at runtime.

Conclusion

The macro works by:

  1. Receiving a quoted representation of a case‑class instance.
  2. Using compile‑time reflection to discover the class’s fields.
  3. Generating quoted code that builds a Map[String, Any] from those fields.
  4. 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.

Back to Blog

Related posts

Read more »

Scala 3 slowed us down?

Article URL: https://kmaliszewski9.github.io/scala/2025/12/07/scala3-slowdown.html Comments URL: https://news.ycombinator.com/item?id=46182202 Points: 3 Comment...