DEV Community

Depa Reddy
Depa Reddy

Posted on

Deep Dive into Scala 3 Macros: Building a Custom String Interpolator

This blog post explores the powerful macro system in Scala 3 through a practical example: a custom string interpolator that adds debug information to interpolated values. We'll break down the code and explain the concepts behind it, giving you the knowledge to create your own macros.

Before we dissect the code, let's cover the basics. If macros sound intimidating, think of them as "meta-programming" tools that let your code think about itself. In Scala 3, macros use Tasty, Scala's internal representation (short for "Typed Abstract Syntax Trees"—a blueprint of your code that the compiler understands and manipulates).

What are macros? Normal code runs when your program executes (runtime). Macro code runs during compilation (when Scala turns your .scala files into runnable bytecode). They can analyze your code, change it, or generate new code on the fly. For example, a macro might see t"Hello $name" and rewrite it to optimized bytecode before the program starts.

Introduction to String Interpolation in Scala

String interpolation in Scala allows embedding expressions directly into string literals. Scala 3 provides several built-in interpolators:

s"Hello $name": Simple interpolation
f"Hello $name%s": Formatted interpolation
raw"Hello\n$name": Raw interpolation
When you write s"Hello $name", the compiler transforms it into:

StringContext("Hello ", "").s(name)

This transformation opens the door to custom interpolators, which we'll explore next.

Understanding the Code Structure

Let's break down our custom interpolator code:

package com.dev24.macros

import scala.quoted.*
import scala.util.*
Enter fullscreen mode Exit fullscreen mode

We start with the necessary imports, particularly scala.quoted.* which is essential for working with macros in Scala 3.

Runtime Helper Methods
Our implementation includes two runtime helper methods:

Runtime Helper Methods
Our implementation includes two runtime helper methods:

buildTaggedString

def buildTaggedString(parts: Array[String], args: Any*): String =
  val taggedArgs = args.map(arg => s"[DEBUG] ${arg.toString}")
  val result = new StringBuilder
  var i = 0
  result.append(parts(0))
  for j <- 0 until args.length do
    result.append(taggedArgs(j))
    result.append(parts(j + 1))
  result.toString

Enter fullscreen mode Exit fullscreen mode

This method:

  1. Takes an array of string parts (the literal parts of the interpolated string)
  2. Takes variable arguments (the expressions to be interpolated)
  3. Wraps each argument with "[DEBUG]" prefix
  4. Constructs the final string by alternating parts and tagged arguments

buildTaggedStringFromContext

def buildTaggedStringFromContext(sc: StringContext, args: Seq[Any]): String =
  val taggedArgs = args.map(arg => s"[DEBUG] ${arg.toString}")
  val parts = sc.parts.map(_.toUpperCase).toArray
  val result = new StringBuilder
  result.append(parts(0))
  for j <- 0 until args.length do
    result.append(taggedArgs(j))
    result.append(parts(j + 1))
  result.toString

Enter fullscreen mode Exit fullscreen mode

This is similar to the previous method but:

  1. Takes a StringContext directly (which contains the parts)
  2. Converts parts to uppercase
  3. Used as a fallback when compile-time analysis isn't possible

The Extension Method

extension (sc: StringContext)
  transparent inline def t(args: Any*): String =
    ${ tMacro('sc, 'args) }

Enter fullscreen mode Exit fullscreen mode

This defines an extension method on StringContext:

  • transparent inline indicates this is an inline method that will be expanded at compile time
  • The method takes variable arguments
  • ${ tMacro('sc, 'args) } is the macro expansion syntax, calling the tMacro function with the StringContext and arguments as quoted expressions

The Macro Implementation

def tMacro(sc: Expr[StringContext], args: Expr[Seq[Any]])(using Quotes): Expr[String] =
  import quotes.reflect.*

  sc match
    case '{ StringContext(${Varargs(partExprs)}*) } =>
      // Try to extract literal parts
      val partsOpt: Seq[Option[String]] = partExprs.map { expr =>
        expr.asTerm match
          case Literal(StringConstant(str)) => Some(str)
          case _ => None
      }

      // If all parts are literal and args are Varargs, do compile-time path
      if partsOpt.forall(_.isDefined) then
        args match
          case Varargs(argExprs) =>
            val parts = partsOpt.map(_.get)
            val processed = parts.map(_.toUpperCase)
            val processedPartsExpr = Expr.ofSeq(processed.map(Expr(_)))
            '{ buildTaggedString($processedPartsExpr.toArray, ${Varargs(argExprs)}*) }
          case _ =>
            // args not Varargs at compile time -> runtime fallback
            '{ buildTaggedStringFromContext($sc, $args) }
      else
        // parts not all literals -> runtime fallback
        '{ buildTaggedStringFromContext($sc, $args) }

    case _ =>
      // sc not a StringContext literal -> runtime fallback
      '{ buildTaggedStringFromContext($sc, $args) }

Enter fullscreen mode Exit fullscreen mode

This is the core macro implementation. Let's break it down:

Function Signature

  • Takes an Expr[StringContext] (quoted expression of StringContext)
  • Takes an Expr[Seq[Any]] (quoted expression of the arguments)
  • Returns an Expr[String] (quoted expression of the result)
  • Requires a Quotes context for macro operations
  • Pattern Matching on StringContext

case '{ StringContext(${Varargs(partExprs)}*) } =>

This matches a StringContext with varargs of part expressions, extracting the literal parts of the interpolated string.

Extracting Literal Parts

val partsOpt: Seq[Option[String]] = partExprs.map { expr =>
  expr.asTerm match
    case Literal(StringConstant(str)) => Some(str)
    case _ => None
}

Enter fullscreen mode Exit fullscreen mode

For each part expression, it tries to extract the literal string value:

expr.asTerm converts the expression to a term

Literal(StringConstant(str)) matches a literal string constant

If not a literal, returns None

Compile-time Path

If all parts are literals and args are varargs, it processes at compile time:

val parts = partsOpt.map(_.get)
val processed = parts.map(_.toUpperCase)
val processedPartsExpr = Expr.ofSeq(processed.map(Expr(_)))
'{ buildTaggedString($processedPartsExpr.toArray, ${Varargs(argExprs)}*) }

Enter fullscreen mode Exit fullscreen mode

This:

  • Converts parts to uppercase
  • Creates a new expression that calls buildTaggedString with the processed parts and original arguments

Runtime Fallback

If parts aren't all literals or args aren't varargs, it falls back to runtime processing:

'{ buildTaggedStringFromContext($sc, $args) }

Key Concepts in Scala 3 Macros

Quotes and Splices

Quotes and splices are the fundamental building blocks of Scala 3 macros:

  • Quotes ('{...}): Capture code as data
  • Splices (${...}): Insert code into quotes
  • In our example:
transparent inline def t(args: Any*): String =
  ${ tMacro('sc, 'args) }

Enter fullscreen mode Exit fullscreen mode

The 'sc and 'args are quotes, and ${ tMacro(...) } is a splice that inserts the result of the macro.

Transparent Inline Methods

transparent inline methods are a key feature for macros in Scala 3:

inline: The method call is replaced with the method body at compile time
transparent: The compiler can see through the method to infer more precise types

This enables the macro to generate code that's type-checked in the context where it's used.

Compile-time vs Runtime Processing

Doing work at compile time has several advantages:

  • Performance: The work is done once during compilation, not every time the code runs
  • **Type safety: **Errors can be caught at compile time
  • Optimization: The compiler can optimize the generated code

In our example, when the string parts are known at compile time, we can process them immediately and generate optimized code.

Example Usage

object LoggerExample extends App {
  import com.dev24.macros.CustomInterpolator._
  val name = "Alice"
  val age = 30
  val msg = t"Hello $name, you are $age years old!"
  println(msg)
}

Enter fullscreen mode Exit fullscreen mode

This demonstrates using the custom interpolator:

  1. Imports the interpolator
  2. Defines some variables
  3. Uses the t interpolator to create a tagged string
  4. Prints the result
  5. The output would be:

HELLO [DEBUG] Alice, YOU ARE [DEBUG] 30 YEARS OLD!

Practical Applications of Custom Interpolators and Macros

Custom interpolators with macros have many practical applications:

  1. Type-safe SQL: Prevent SQL injection by validating queries at compile time
  2. Configuration: Validate configuration files at compile time
  3. Logging: Add metadata to log messages automatically
  4. Internationalization: Validate that all translation keys exist
  5. Code generation: Generate boilerplate code based on annotations

Our example demonstrates a logging interpolator that adds debug tags to interpolated values.

Conclusion

This code showcases the power of Scala 3's macro system through a custom string interpolator. It demonstrates how to:

  1. Define an extension method on StringContext
  2. Implement a macro that analyzes code at compile time
  3. Fall back to runtime processing when necessary
  4. Generate optimized code when possible

The macro system in Scala 3 is more approachable and safer than in Scala 2, making it a powerful tool for metaprogramming and code generation. By understanding these concepts, you can create your own macros to optimize your code, improve type safety, and reduce boilerplate.

Whether you're building a type-safe DSL, optimizing performance-critical code, or simply reducing boilerplate, Scala 3 macros provide a powerful and elegant solution.

full source code can be found here:

Top comments (0)