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.*
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
This method:
- Takes an array of string parts (the literal parts of the interpolated string)
- Takes variable arguments (the expressions to be interpolated)
- Wraps each argument with "[DEBUG]" prefix
- 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
This is similar to the previous method but:
- Takes a StringContext directly (which contains the parts)
- Converts parts to uppercase
- 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) }
This defines an extension method on StringContext:
-
transparent inlineindicates 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) }
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
Quotescontext 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
}
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)}*) }
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) }
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)
}
This demonstrates using the custom interpolator:
- Imports the interpolator
- Defines some variables
- Uses the t interpolator to create a tagged string
- Prints the result
- 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:
- Type-safe SQL: Prevent SQL injection by validating queries at compile time
- Configuration: Validate configuration files at compile time
- Logging: Add metadata to log messages automatically
- Internationalization: Validate that all translation keys exist
- 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:
- Define an extension method on StringContext
- Implement a macro that analyzes code at compile time
- Fall back to runtime processing when necessary
- 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)