Skip to content

Best Practices

How to make a macro more user-friendly?

I recommend working with a macro as if it were a server endpoint that you'd deploy to call remotely:

  • you'd either receive a successful result or an error; if it's an error, it should provide enough information for the user to correct all the issues at once (so inner errors should be aggregated when possible)
  • errors should be informative, so it's good to create a dedicated algebraic data type for them - it's easier to record all information that caused an issue into a case class and then, before exiting from a macro, combine a non-empty collection of errors, perhaps using their structure for grouping issues together, than to turn a bunch of strings into something decent
  • if you need to investigate, assume you can't always just add random printlns - if there is some log of what actually happened, then you or your user can investigate without having to edit and redeploy the code
  • if you need to provide a config to a macro, the global configs should be provided via a dedicated -Xmacro-settings parameter (much more reliable than using some JVM -D option!), and local configs should be handled by the macro's DSL or, e.g., implicits
  • macros should not generate warnings that users of -Xfatal-errors would have to manually suppress

How does it translate to macros?

Use sanely-automatic type class derivation

In traditional semi-automatic derivation, the macro generates a single type class instance and relies on implicit resolution to recurse into nested types. This means users must ensure that instances exist for every component type, and recursive types require manual intervention (e.g. Lazy wrappers or explicit definitions) to avoid divergent implicit search.

Sanely-automatic derivation flips this: the macro itself walks the type tree, generating all the necessary logic — including recursive calls — in a single expansion. From the user's perspective, a single derived call handles the entire type hierarchy. Error messages are better because the macro knows the full derivation context when it fails, and recursive types work out of the box without any special treatment.

This approach was pioneered by jsoniter-scala for codec generation, and refined by Chimney for type transformations. Hearth's utilities (CaseClass.parse, Enum.parse, SingletonValue.parse, parMatchOn, etc.) are designed to make implementing this pattern straightforward.

For the full rationale, see the blog post. For the ecosystem story behind this approach, see Prior Art & Influences. For a step-by-step implementation guide, see the Derivation Checklist.

An example is shown on the landing page.

Aggregate errors instead of failing fast

While you can immediately fail with something like:

Immediate abortion

//> using dep com.kubuszok::hearth:0.3.0
import hearth.MacroCommons

trait YourMacros { this: MacroCommons =>

  def macroImpl: Expr[String] =
    Environment.reportErrorAndAbort("Error has happened")
}

you might want instead to use something like:

NonEmptyVector for error aggregation

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
import hearth.fp.data.NonEmptyVector

val result: Either[NonEmptyVector[Throwable], String] =
  Left(NonEmptyVector(new Exception("your error")))

We could gather errors in a collection and only right before exiting from the macro we would combine all the errors into a single String.

And we can always make:

Helper DerivationError - a stackless Exception

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
import hearth.fp.data.NonEmptyVector

sealed trait DerivationError
    extends scala.util.control.NoStackTrace
    with Product
    with Serializable
object DerivationError {
  final case class NotSupported(msg: String) extends DerivationError
  final case class Private(arg: String) extends DerivationError
  // ...
}

val result: Either[NonEmptyVector[Throwable], String] =
  Left(NonEmptyVector(DerivationError.NotSupported("your msg")))

Micro FP provides methods like .parTraverse to map collections and aggregate their fallible results. If you don't like the provided types, you can implement your own.

Results for each case class field/enum subtype are perfect candidates for such result aggregation.

Store potentially large/recursive logic as defs

Instead of inlining the whole logic into a single, giant expression, consider splitting it into several smaller defs. This approach:

  • allows reusing some logic you generated once in another place that needs the same logic (e.g. during a type class derivation)
    • it also allows generating recursive code without tricks that make the whole type class instance lazily initialized
  • prevents issues with a method's body exceeding the allowed size (compilation fails if bytecode would exceed 64kB)
  • helps the JVM compile and optimize the code, since smaller methods are easier to analyze and profile

Use ValDefs and combine them with .map2/.parMap2 and/or use ValDefsCache to define multiple defs in scope, use them, and prepend before the final expression.

Allow previewing macro logic

Macros allow us to show some information which is not an error:

Hint from a macro

//> using dep com.kubuszok::hearth:0.3.0
import hearth.MacroCommons

trait YourMacros { this: MacroCommons =>

  def macroImpl: Expr[String] = {
    // This will produce an info message during compilation/in IDE/in Scastie
    Environment.reportInfo("Some information")
    // This will produce a warning message during compilation/in IDE/in Scastie
    Environment.reportWarn("Some warning")

    Expr.quote { "result" }
  }
}

It would be visible:

  • in the console during compilation
  • as a hint in IDE (Metals with e.g. VS Code, IntelliJ)
  • as a hint in Scastie

which isn't always true about printlns (which would disappear if users compile using some compilation server).

To decide whether we want the logs shown, we could, e.g., provide a scalac option that would turn it on globally, or an implicit which would enable these hints whenever it's imported into the scope:

Dedicated type to enable showing logs

/** Import [[LogDerivation]] in the scope to preview how the derivation is done.
  *
  * Put outside of companion to prevent the implicit from being summoned automatically!
  */
implicit val logDerivation: LogDerivation = LogDerivation()

/** Special type - if its implicit is in scope then macros will log the derivation process.
  * 
  * Let's say it's in the `example` package.
  */
sealed trait LogDerivation
object LogDerivation {
  private object Instance extends LogDerivation
  def apply(): LogDerivation = Instance
}

Deciding whether or not to show log

// inside a trait with this: MacroCommons =>
val LogDerivation: Type[example.LogDerivation] = Type.of[example.LogDerivation]

/** Enables logging if we either:
  *   - import [[logDerivation]] in the scope
  *   - have set scalac option `-Xmacro-settings:show.logDerivation=true`
  */
def shouldWeLogDerivation: Boolean = {
  implicit val LogDerivation: Type[example.LogDerivation] = LogDerivation
  def logDerivationImported = Expr.summonImplicit[example.LogDerivation].isDefined

  def logDerivationSetGlobally = (for {
    data <- Environment.typedSettings.toOption
    show <- data.get("show")
    shouldLog <- show.get("logDerivation").flatMap(_.asBoolean)
  // We don't want to fail the derivation
  // if we can't parse the settings.
  } yield shouldLog).getOrElse(false)

  logDerivationImported || logDerivationSetGlobally
}

if (shouldWeLogDerivation) {
  Environment.reportInfo("Some information")
}

Unfortunately, on both Scala 2 and Scala 3, only the first such call provides a hint. All the following will be no-ops, so we would have to aggregate the individual logs somehow.

One such approach would be to use a mutable collection to write to. Or passing around an immutable collection, and have it updated (the Writer monad).

Use namespaces in Xmacro-settings

-Xmacro-settings is a global Scalac option intended to pass information to macros - but all macros see all the settings.

To make sure that several projects won't accidentally use the same setting name for a different thing use some prefix e.g. name-of-your-library. before the actual setting. Such prefixes will also be friendly to parsing settings as hearth.data.Data format.

Suppress warnings

It's bad UX if your users have to suppress the warnings for the code they haven't even written in the first place.

Consider generating code looking more or less like:

{
  @scala.annotation.nowarn // suppress the compiler's linters
  // suppress Wartremover and Scapegoat lints
  @SuppressWarnings("org.wartremover.warts.All", "all")
  val result = ...
  result
}

and testing your macros with a reasonably large number of compiler linters enabled.

How to make a macro more maintainable?

I recommend working with a macro as if it were any other business logic that you would have to maintain for a while:

  • define an ADT for errors instead of passing around only raw Strings - you can turn them into an error message at the end of the derivation
  • define helper methods and types, split large chunks of logic into smaller ones, give them high-level names that explain what you are trying to achieve
  • write tests for your macros - while they are unit tests (expand macro, check its results), since you cannot mock inside of a macro, it might feel a bit like small integration tests
  • if you are used to conventions of Cats/Scalaz/ZIO, you might want to work with something like an IO monad to reuse your intuitions and habits
  • use parTraverse and Parallel when combining several independent results, e.g. results computed for each field of a case class or each subtype of an enum
  • cache intermediate results, by generating internally defs instead of multiple type class instances - a bunch of local defs do not require additional allocations, and might potentially allow your type class to be recursive
  • separate reusable runtime utilities from the code that has to be generated during compilation - and check the runtime utilities with MiMa

How it translates to macros?

Use the Macro IO monad

If you like to work with Cats/Scalaz/ZIO and would like to reuse your experience with these libraries, then there is an optional MIO (Macro IO) monad.

The name only refers to how similar it is in usage to IO monads from established ecosystems, since there is little need to use actual IO in macros. However:

  • it is lazy, non-memoized, and catches NonFatal exceptions

    MIO type

    import hearth.fp.effect.*
    
    val i: MIO[Int] = MIO {
      "this might throw".toInt
    }
    
  • it uses hearth.fp.data.NonEmptyVector[Throwable] as its error type already, allows both monadic composition (with fail-fast semantics) and Parallel (.parMap2, .parTraverse), which aggregates the errors from multiple MIOs

    .parTraverse example

    import hearth.fp.effect.*
    import hearth.fp.instances.*
    import hearth.fp.syntax.*
    
    list.parTraverse { (item: A) =>
      mioResult(item) // : MIO[B]
    } // : MIO[List[B]] aggregating errors from each `mioResult`!
    
  • it provides MLocal for controlled mutation

    MLocal example

    import hearth.fp.effect.*
    
    val counter = MLocal(initial = 0, fork = i => i + 1, join = (a, b) => a max b)
    
    // This is just a recipe for computation, it's not executed yet.
    // In this recipe we are reading the current value
    // of the counter, and logging it to 3 different levels.
    val printSth = for {
      i <- counter.get
      _ <- Log.info("Print info: counter is now $i")
      _ <- Log.warn("Print warning: counter is now $i")
      _ <- Log.error("Print error: counter is now $i")
    } yield 1
    
  • it provides Log utility for appending scoped logs:

    Log example

    import hearth.fp.effect.*
    
    Log.nestedScope("New nested scope") {
      Log.info("Started doing X") >>
        someMioOperation(args) *>
        Log.info("Done doing X")
    }
    
  • it provides async-await operations for cases when monadic/parallel interfaces would be inconvenient or impossible to use, e.g.

    async-await (direct style) example

    import hearth.fp.effect.*
    
    MIO.async { await =>
      Expr.quote {
        new Show[A] {
    
          def show(a: A): String = Expr.splice {
            // good luck handling it with for-comprehension
            await( errorReturningMethod(Expr.quote { a }) )
          }
        }
      }
    }
    

If the whole derivation was handled in MIO, and the result is some MIO[Expr[A]], then you could, at once:

  • print its logs
  • return a successful Expr[A] or
  • combine failures into a single error message

with:

Handle logging, showing errors and/or returning result at once

//> using dep com.kubuszok::hearth:0.3.0
//> using scala 3.3.7
import hearth.MacroCommons
import hearth.fp.effect.*

trait YourMacros { this: MacroCommons =>

  /** Just an example, so we won't implement it */
  def computeMioResult[A: Type](expr: Expr[A]): MIO[Expr[String]]

  /** Converts the [[MIO]] results into an [[Expr]] or error message. */
  def deriveOrFail[A: Type](value: Expr[A]): Expr[String] =
    Log.namedScope(s"Derivation for ${Type.prettyPrint[A]}") {
      computeMioResult(value)
    }
    .runToExprOrFail("Our macro derivation") { (errorLogs, errors) =>
      val errorsStr = errors.toVector.map { error => error.getMessage  }.mkString("\n")

      s"""Failed to derive Show for ${Type.prettyPrint[A]}:
         |$errorsStr
         |Error logs:
         |$errorLogs
         |""".stripMargin
    }
}

However, all of these are completely optional, if you are not fond of this style of programming, then you can simply not use it.

Use named scopes with Logs

If you are computing some smaller part of a whole expression returned by a macro, it's worth wrapping it in its own named scope. When rendering the whole log the nesting will help telling the story of what actually happened, and what is related to what.

Nested logs with named scope

//> using dep com.kubuszok::hearth:0.3.0
//> using scala 3.3.7
import hearth.fp.effect.*

Log.namedScope("I want all logs here grouped and indented!") {
  Log.info("log 1") >>
    Log.info("log 2") >>
    Log.info("log 3") >>
    Log.info("log 4")
}

Use MiMa to check all your runtime code

...and make sure that macros are only calling these runtime utilities that MiMa is checking.

Then you can rewrite all your macro's internals and change what code is output by a macro - if the interfaces you call, and the type you instantiate haven't broken compatibility, the generated code will keep working even if it is linked against a newer runtime version.

But to make sure that the behavior of the code generated by a macro is the same (minus fixed bugs) you have to write tests.

To plan your API changes in advance, and check how you could evolve it without breaking changes for the user, try Mimalyzer.

For broader guidance on publishing and maintaining Scala libraries, see the Library Author Guide and Resources & Further Reading.

How to debug macros, implicits and other metaprogramming utilities?

Debugging macros, implicit resolution, and compiler behavior in Scala relies on a mix of compiler flags, AST inspection, and controlled instrumentation. The techniques below are well-established, documented, and commonly used in real compiler and library work.

If you want to write a library that uses metaprogramming under the hood, you will have to learn these eventually. But try to provide your users good enough error messages (and some debugging utilities e.g. logging from a macro if an implicit is present), that they won't have to learn these themselves.

Inspect macro expansion (Scala 2)

It's useful if we want to see how some macro would expand. But since it shows results for every macro expansion in a single compilation unit, I'd recommend using it in some REPL or a dedicated module, and only work on a single case reproducing the issue we are trying to investigate.

If only the final tree is of interest, consider Hearth's debugging utilities instead.

What it gives you:

  • Visibility into macro expansion steps
  • Ability to see generated trees without modifying macro code

How:

  • Add to scalac options macro debug flags:
    • -Ymacro-debug-lite
    • -Ymacro-debug-verbose
  • You can control the expansion:
    • -Ymacro-expand:none|normal|discard

When to use:

  • Macro expands incorrectly or unexpectedly
  • Expansion differs between call sites
  • You want compiler-side visibility without printlns

Sources:

Inspect compiler phases

It's useful when debugging a compiler plugin, or if want to find out how some phase is named to print its output. (The later is virtually unusable in larger codebases, so it only makes sense if we can isolate a small snipped reproducing our issue, as it will still produce several screens of output).

What it gives you:

  • A complete list of compiler phases
  • Ability to understand where a transformation or failure happens

How:

  • List phases by:
    • Scala 2 & 3: -Vphases / -Xshow-phases
  • Print trees after specific phases:
    • -Vprint:<phase>
    • -Vprint-types
    • -Vprint-pos
  • Interactive AST browser:
    • -Vbrowse:<phase> / -Ybrowse
  • Additional compiler-level debugging switches
    • Phase logging: -Vlog:<phase>
    • Typer tracing: -Vtyper
    • Tree validation: -Ycheck:<phase>
    • Interactive error prompt: -Xprompt

When to use:

  • Macro expansion happens but later phases break it
  • Implicit is resolved but erased/transformed unexpectedly
  • You need to know which phase introduced or removed a tree

Sources:

Inspect implicit resolution

It's useful when the issue we have is not a macro expansion, but understanding which implicits are generated (any perhaps which are missing). It's a more "pro" alternative to the "binary search" approach.

If implicit resolution succeeds, and you only want to see what exactly it generated, consider Hearth's debugging utilities instead.

What it gives you:

  • Full trace of implicit search
  • Explanation why a candidate was rejected
  • Compact or verbose resolution trees

How:

  • Legacy logging:
    • -Xlog-implicits
  • Modern (splain-based) diagnostics:
    • -Vimplicits
    • -Vimplicits-verbose-tree
    • prior to 2.13.16 required a splain compiler plugin
  • Explain mode:
    • Scala 3: -explain, -explain-types

When to use:

  • “Implicit not found” with many candidates
  • Ambiguous implicit errors
  • Typeclass derivation failures

Sources:

“Binary search” implicit resolution failures

It's useful if you suspect that the investigation would be quick, and you can actually save some time but not editing your build tool's compler's options. Or if you don't want to read long diagnostic outputs, or need a debugging that could be performed by a LLM, just by checking if there are compilation errors.

If there is a lot of implicits, no obvious candidated where to start, and no clear idea which implicits are already manually defined in the scope (rather than imported into it as low priority), this approach can be rather tedious.

(It's also a great showcase why macro-based recursive derivation with some logging and user-oriented error messages, can offer better user experience than Shapeless or Mirror-based approach).

What it gives you:

  • Isolation of which implicit candidate causes failure
  • Understanding of search scope and priority

How:

  • Introduce temporary/stub implicits:
    • for compilation it's enough to write e.g. implicit val fooEncoder: Encoder[Foo] = ???
    • if you debugging e.g. case class create such stub for each field's type (avoiding duplicates!), if it's sealed/enum, create it for every case, etc
    • do not add a stub if you know that you defined such implicit in the same scope!
    • force resolution with implicitly[T], summon[T] (on Scala 3) or library's summon method
    • add/remove stubs until the code compiles
  • Comment out half of the stubs, and check if it still compiles
    • If it does, uncomment them and comment out the other half, check if it compiles
  • (Assuming it's only 1 implicit that's missing/ambiguous), now you should have 2 groups of stubs: one proved to not be needed (import or implicit in companion can be picked up), and one that is
  • Remove the unnecessary group of stubs
  • Commend out half of the remaining ones, and repeat the process
  • Continue until you identified the stub that is necessary to work:
    • if it's a type that cannot be derived - check if you are yet to define it, or have you missed importing it
    • if it's a type that can be derived, but it failed - you have to repeat the process for all its components (implicits for fields or subtypes)

When to use:

  • Large implicit scopes
  • Derivation stacks with multiple fallback instances
  • Confusing error messages even with -Vimplicits

Sources:

Printing and inspecting trees inside macros (Scala 2)

It's useful when working on a macro, and you want to e.g. create some code by hand, pass it to macro to print it, and learn the shape of AST to create it yourself. Or if you already build the AST to check if it does what you want.

If you want better printing output (syntax highlighting, indentation), consider better-printers.

If you are using Hearth's basic utilities, expr.prettyPrint, expr.prettyAST, tpe.prettyPrint, etc. is already using better printers under the hood!

If you only want to debug one macro, and avoid printing intermediate results for every expansion, consider Hearth's tools for selective debugging.

What it gives you:

  • Raw and desugared ASTs
  • Generated source-like output

How:

  • showRaw(tree)
  • showCode(tree)

Sources:

Printing and inspecting trees inside macros (Scala 3)

It's useful when working on a macro, and you want to e.g. create some code by hand, pass it to macro to print it, and learn the shape of AST to create it yourself. Or if you already build the AST to check if it does what you want.

If you want better printing output of AST (syntax highlighting, indentation), consider better-printers.

If you are using Hearth's basic utilities, expr.prettyPrint, expr.prettyAST, tpe.prettyPrint, etc. is already using better printers or .show(...) under the hood!

If you only want to debug one macro, and avoid printing intermediate results for every expansion, consider Hearth's tools for selective debugging.

What it gives you:

  • Typed and untyped trees
  • Structured or code-like output

How:

  • tree.show(using Printer.TreeStructure)
  • tree.show(using Printer.Code)

Sources:

How to test macros effectively?

Testing macros is challenging because they cannot be tested in the same compilation unit where they are defined. Hearth provides the hearth-munit module with specialized testing utilities to make macro testing easier and more maintainable.

Installation

To use hearth-munit in your tests:

sbt

JVM only:

libraryDependencies += "com.kubuszok" %% "hearth-munit" % "0.3.0" % Test

JVM/Scala.js/Scala Native via sbt-crossproject, sbt-projectmatrix or sbt 2:

libraryDependencies += "com.kubuszok" %%% "hearth-munit" % "0.3.0" % Test

Scala CLI

JVM only:

//> using test.dep "com.kubuszok::hearth-munit:0.3.0"

JVM/Scala.js/Scala Native:

//> using test.dep "com.kubuszok::hearth-munit::0.3.0"

Warning

hearth-munit depends on the core Hearth library, so if you add hearth-munit, you'll automatically get hearth as well.

Test suite types

hearth.Suite

The base test suite providing: - Test grouping with group() method for organizing tests hierarchically - Simple assertions with ==> operator for any type equality - Specialized assertions with <==> operator for String and hearth.data.Data with diff output

hearth.MacroSuite

Extends Suite with compile-time error checking utilities: - Check error presence: compileErrors("code").check("expected text in error") - Check error absence: compileErrors("code").checkNot("text that should not appear") - Verify errors exist: compileErrors("code").arePresent()

hearth.ScalaCheckSuite

Combines Suite with ScalaCheck for property-based testing, with Hearth-specific Arbitrary instances for hearth.data.Data structures.

Testing multiple properties with Data

The hearth.data.Data structure is a JSON-like format that allows testing multiple related properties in a single macro expansion.

Why use Data? - Macros cannot be tested in the same compilation unit where they're defined - Creating separate macros for each property is tedious and slow to compile - Data lets you gather multiple test assertions from one macro invocation

Pattern:

  1. Create a fixture macro that returns Expr[Data]
  2. Expose as a macro in Scala 2/3 adapters
  3. Test multiple properties in your spec using <==> for clear diffs

Complete macro testing example

// file: src/main/scala/myfeature/MyFeatureFixtureImpl.scala - part of macro testing example
//> using scala 3.3.7
//> using dep com.kubuszok::hearth:0.3.0

package myfeature

import hearth.*
import hearth.data.Data
import hearth.std.*
import scala.quoted.*

object MyFeatureFixtureImpl {
  def testTypeInfo[A](using q: Quotes, tpe: Type[A]): Expr[Data] = {
    val mc = new MacroCommonsScala3(using q) {}
    import mc.*

    // Strip ANSI color codes from prettyPrint output for cleaner test comparisons
    val typeName = Type[A].prettyPrint.replaceAll("\u001b\\[([0-9]+)m", "")

    Expr(Data.map(
      "typeName" -> Data(typeName),
      "isSealed" -> Data(Type[A].isSealed)
    ))
  }
}
// file: src/main/scala-3/myfeature/MyFeatureFixture.scala - part of macro testing example
//> using target.scala 3.3.7
//> using plugin com.kubuszok::hearth-cross-quotes::0.3.0

package myfeature

import hearth.data.Data
import scala.quoted.*

object MyFeatureFixture {
  inline def testTypeInfo[A]: Data = ${ MyFeatureFixtureImpl.testTypeInfo[A] }
}
// file: src/test/scala/myfeature/MyFeatureSpec.scala - part of macro testing example
//> using test.dep com.kubuszok::hearth-munit:0.3.0

package myfeature

import hearth.MacroSuite
import hearth.data.Data

final class MyFeatureSpec extends MacroSuite {
  test("Option[Int] type info") {
    MyFeatureFixture.testTypeInfo[Option[Int]] <==> Data.map(
      "typeName" -> Data("scala.Option[scala.Int]"),
      "isSealed" -> Data(true)
    )
  }
}

Assertion operators

Use ==> for simple equality assertions on any type. On failure, shows: expected: X but got: Y

Use <==> for String and hearth.data.Data comparisons with detailed diff output. Essential for comparing multi-line strings or complex Data structures.

When to use which: - Use ==> for: primitives, simple objects, small collections, boolean conditions - Use <==> for: macro-generated strings, error messages, Data structures from fixtures

Benefits of this approach

  • Single macro expansion tests multiple related properties
  • <==> operator provides clear diff when any property fails
  • Easy to add new properties without creating new macros
  • Scales well for complex macro behavior validation