Scala Compile-Time Macros

Explore Scala's compile-time macros — Scala for Python Developers.

Focus: explore scala's compile-time macros

Sponsored

You've mastered pattern matching, implicits, and type classes in Scala, but you've probably felt the itch: wouldn't it be amazing if the compiler could generate all that repetitive boilerplate for you? That's exactly what Scala's compile-time macros do. In Python, you might reach for decorators or metaclasses to automate behavior at runtime, but Scala's macros run during compilation, giving you zero runtime overhead and the full power of the language to analyze and transform your code. This lesson explores compile-time macros in Scala 3, showing you how they work, where they shine, and how to avoid their sharp edges — all from the perspective of a Python developer who already knows what metaprogramming can do.

The problem this lesson solves

Suppose you're writing a REST client in Python. You might use a decorator to auto-generate parsing logic, and it works fine at runtime. In Scala, you get the same problem: writing boilerplate for every endpoint, every case class, every JSON serialization, and every validation rule. But unlike Python, where decorators run at import time and add overhead (or fail silently), Scala's compile-time macros let you run real code while the compiler is running. They can inspect your source code, pattern-match on your types, and generate new code that is compiled, type-checked, and optimized before your application ever starts.

The pain point: without macros, you either hand-write repetitive code (error-prone and slow) or you rely on runtime reflection (slow, fragile, and opaque). Scala's macros give you the best of both worlds: compile-time safety and runtime performance. This lesson teaches you the core concepts, the incremental steps to write your first macro, and the tools you need to debug them when things go wrong.

Core concept / mental model

Think of Python's metaclasses: they let you intercept class creation and modify it before instantiation. Scala's compile-time macros are analogous, but they operate at the source code level. When the compiler encounters a macro invocation, it executes a macro implementation function. That function receives a representation of the code (a Term in Scala 3) and returns a new representation that replaces the original call. The result is compiled as if you wrote it by hand — types are checked, and the generated code is fully optimized.

Mental model: a macro is a function that runs during compilation. It takes syntax in and returns syntax out. It's like a code generator that lives inside your IDE and runs every time you hit Compile.

In Scala 3, the macro system lives in the scala.quoted package. Here's a quick vocabulary:

  • Expr[T] — a typed abstract syntax tree representing an expression of type T.
  • Quotes — the context object that gives you access to the compiler's API.
  • ${ ... } — the splicing syntax that inserts code into a macro call.
  • '{}quote syntax that captures code as an Expr.

If you've used Python's ast module, you're already familiar with working with syntax trees. In Scala, you get a typed tree, so the compiler can help you build valid code.

How it works step by step

Let's trace the lifecycle of a macro invocation:

  1. Write a macro definition and its implementation. The definition is just a method that returns an Expr[T]. The implementation is the body that will be executed during compilation.
  2. Call the macro somewhere in your code, e.g., generateHello("world").
  3. The compiler sees the call, finds the macro implementation, and invokes it at compile time. The implementation receives the quoted arguments (as Exprs) and a Quotes context.
  4. The implementation builds a new expression using quoted code '{ ... } and splicing ${ ... }. It can also inspect the input expressions, serialize them, or even run them (if they are constant) using valueOrError.
  5. The compiler replaces the original call with the new expression and continues type-checking. If the generated expression has type errors, they are reported just like any other compiler error.
  6. The compiled bytecode is optimized and the macro logic never runs at runtime — it's zero overhead.

A key difference from Python: in Python, decorators can run arbitrary code at import time, and failures happen at import time. In Scala, macro errors are compile-time errors with line numbers and useful messages. That insight alone changes how you design your code: you can push more validation into the compiler, catching bugs before they hit production.

Hands-on walkthrough

Let's start with a simple example: a macro that evaluates a constant expression and emits the result. We'll use Scala 3, which is the recommended version in this track. Ensure you're on Scala 3.3+ and have a build tool like sbt or Scala CLI.

Project setup

If you're using sbt, add the following to build.sbt:

lazy val macros = project
  .in(file("macros"))
  .settings(
    scalaVersion := "3.3.1",
    libraryDependencies += "org.scala-lang" %% "scala3-compiler" % scalaVersion.value
  )

lazy val app = project
  .in(file("app"))
  .dependsOn(macros)
  .settings(scalaVersion := "3.3.1")

Note: Macros are typically defined in a separate module because they need access to the compiler. The consuming module depends on the macro module.

A trivial constant-folding macro

First, define the macro definition in the macros module. The definition method is just a stub; the implementation is what runs at compile time.

// macros/src/main/scala/myMacros.scala
package example

import scala.quoted.*

def myConst(x: Int): Int = macro myConstImpl

private def myConstImpl(x: Expr[Int])(using Quotes): Expr[Int] =
  // 'x is a quoted expression; we can try to get its value
  x.value match
    case Some(v) => Expr(v * 2)  // constant fold: multiply by 2
    case None => report.errorAndAbort("Expected a literal value", x)

In your app module, call the macro:

// app/src/main/scala/Main.scala
package example

object Main:
  def main(args: Array[String]): Unit =
    println(myConst(21))  // prints 42

Compile and run it. You'll see 42. The macro evaluated 21 * 2 at compile time and inlined the constant.

Inspect and transform code

Now let's see how to generate code dynamically. We'll create a macro that prints a message when a function is called (like a Python decorator that logs). In Scala 3, you can't easily insert logging calls into an existing method without using Expr transforms, but you can generate a wrapper.

Actually, a more useful example: generating a debug string for a class. In Python, you use __repr__. In Scala, we can write a macro to auto-generate a toString method.

// macros/src/main/scala/autoToString.scala
package example

import scala.quoted.*

// Macro annotation is possible but requires experimental plugin.
// Instead, we'll write a macro that returns a String expression.

def describe[A](x: A): String = macro describeImpl[A]

private def describeImpl[A: Type](x: Expr[A])(using Quotes): Expr[String] =
  import quotes.reflect.*
  val code = x.show    // get the source code as a string
  '{ s"Value of expression is: ${ $x } and code was: $code" }

Usage:

val num = 42
describe(num)
// Output: "Value of expression is: 42 and code was: num"

The show method gives you the source text of the expression, which is handy for debugging.

Safe compile-time validation

A powerful use case is compile-time validation, similar to Python's type hints but deeper. Let's create a macro that enforces that a string is not empty at compile time (if it's a literal):

// macros/src/main/scala/valid.scala
package example

import scala.quoted.*

def nonEmpty(name: String): String = macro nonEmptyImpl

private def nonEmptyImpl(name: Expr[String])(using Quotes): Expr[String] =
  name.value match
    case Some(v) if v.trim.isEmpty => report.errorAndAbort("String must not be empty", name)
    case _ => name

In your code:

val good = nonEmpty("hello")  // compiles fine
val bad = nonEmpty("   ")     // compile error!

The second line will produce a compilation error with your custom message, right at the macro invocation.

Expected output: The first line compiles; the second line fails with error: String must not be empty.

Compare options / when to choose what

You have several metaprogramming tools in Scala, and each has its niche:

Tool When to use Pros Cons
Inline methods Simple code substitution, constant folding Simple, no quotes needed Limited to compile-time constant expressions
Macros (Expr) Full code generation, AST inspection Extremely powerful, type-safe Requires separate module, easier to confuse beginners
Runtime reflection When you need to inspect classpath at runtime Works with unknown types Slow, fragile, no type safety
Type classes + implicits Polymorphism, ad-hoc behavior Safe, idiomatic, no macro complexity Boilerplate remains

When to choose macros? Use macros when you need to:

  • Generate code based on type structure (e.g., automatic serialization).
  • Validate literal arguments at compile time.
  • Eliminate runtime reflection overhead.

When to avoid macros? If your logic can be expressed with type classes or inline methods, prefer simpler tools. Macros increase compile time and complexity; use them sparingly.

Troubleshooting & edge cases

Macros are powerful, but they come with surprises. Here are the common pitfalls:

  • "Cannot use a macro in the same module" — Macros must be defined in a separate compilation unit from the code that calls them. Solution: place macros in a separate subproject or module.
  • Type mismatch in generated code — Your generated expression must type-check. If you're splicing values, make sure they match types exactly.
  • value returns None for non-literal arguments — If a macro argument isn't a constant, x.value gives None. Handle it gracefully, or use valueOrError to get a clearer error.
  • Quotes need an implicit Quotes context — Every macro implementation must take (using Quotes). Forgetting it leads to cryptic errors.
  • Overquoting — Using '{ $x } when x is already an expression results in double quotation. Use $x directly without quotes around it.
  • Performance impact on compile time — Macros run at compile time; complex logic can slow builds. Keep them lean.

What you learned & what's next

You've explored Scala's compile-time macros, the cornerstone of advanced metaprogramming. You now understand how they differ from Python's runtime decorators, how to structure a macro project, and how to debug common issues. You've seen three practical examples: constant folding, code inspection, and compile-time validation. These skills extend directly to real-world libraries like circe for JSON serialization, tapir for HTTP endpoints, and quill for database queries — all use macros under the hood.

Next lesson: In the next step of the Scala for Python Developers track, you'll dive into type-level programming with match types, which builds on macros to encode logic entirely at the type level. You'll combine macros and match types to create powerful, zero-runtime-cost abstractions.

Keep practicing — write a macro that generates a default value for a case class, or one that logs every function call in a block. You're on your way to Scala mastery!

Practice recap

Create a macro in a separate module that takes a numeric literal and returns its square at compile time. Call it from a main app with literal arguments (should compile) and with a runtime variable (should raise a compile-time error). Test the error message and adjust it to be user-friendly.

Common mistakes

  • Using macros in the same module that calls them — Scala requires macros to be defined in a separate compilation unit, or you'll get a 'cannot use macro' error.
  • Forgetting to take (using Quotes) as an implicit parameter in the macro implementation, leading to confusing 'Quotes' not found errors.
  • Assuming x.value always returns Some — it only works for literal expressions; handle None with report.errorAndAbort.
  • Splicing Expr types incorrectly, e.g., writing '{ $x } when x is already an Expr, causing double quoting and type errors.
  • Not using a separate macro module in build.sbt, which is required for macro definitions to access compiler APIs.

Variations

  1. Scala 2 macros using scala.reflect.macros.blackbox.Context offer reflection API, but Scala 3's scala.quoted is safer and simpler.
  2. Use inline methods with inline and constValue for simple constant folding without full macro overhead.
  3. Third-party libraries like scala-newtype or scala-records provide macro-based annotations for common tasks, showing macro usage patterns.

Real-world use cases

  • Auto-generating JSON serializers for case classes with circe via macros, eliminating handwritten parsers.
  • Compile-time SQL validation in quill DSL, checking table and column names before deployment.
  • Generating REST client stubs from trait definitions using tapir, so endpoint methods are automatically implemented.

Key takeaways

  • Scala's compile-time macros execute during compilation, providing zero runtime overhead compared to Python's runtime metaprogramming.
  • The core API is scala.quoted with Expr representing typed syntax and ${} for splicing.
  • Macros must be in a separate module from the code that calls them to access compiler APIs.
  • Common use cases include automatic code generation, compile-time validation, and eliminating reflection.
  • Troubleshooting involves handling non-literal arguments with value/valueOrError and correctly managing Quotes.
  • Prefer simpler alternatives like inline methods or type classes when macros aren't strictly necessary.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.