The Builder Macro

Views
Article hero image

Some astute readers of the previous post on the builder pattern highlighted the verbosity of the pattern. The builder pattern is a powerful pattern, but it comes with the baggage of boilerplate code.

One even asked if it could be automated using macros. The answer is yes! The good thing is that the mechanics of the pattern can be abstracted away using macros. Although cryptic, Scala 2 macros offer a powerful way to generate boilerplate code. In this post, we will explore how to use macros to generate builder pattern code.

Let’s recap what is involved in creating the builder class for a given case class.

  • Define the builder class with a companion object.
  • The builder class should have a constructor that takes the same type parameters and fields as the case class.
  • Implement the builder methods to set the fields of the case class.
  • Implement the build method to create an instance of the case class.

All of the above steps are based on the field types and fields of the case class. A macro can be used to automate the generation of the builder class and its component parts.

The first thing to decide is how to invoke the generation of the builder class. We can use a macro annotation to achieve this.

@compileTimeOnly("enable macro paradise to expand macro annotations")
class builder extends StaticAnnotation {
  def macroTransform(annottees: Any*): Any = macro BuilderMacro.impl
}

object BuilderMacro {
  // ... more coming below ...
}

You can use it as follows:

@builder
case class Person(name: String, age: Int, email: String)

Invocation of the macro results in the generation of a builder class with the same type parameters and fields as the case class.

Setting up the builder macro object

First, we have to setup the constraints when the macro is relevant. For instance, a case class with at least one parameter.

def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = {
  import c.universe._

  def abort(msg: String) = c.abort(c.enclosingPosition, msg)

  c.echo(c.enclosingPosition, "Builder macro is being invoked!")

  annottees.map(_.tree) match {
    case (classDef: ClassDef) :: companion =>
      classDef match {
        case q"$mods class $className[..$tparams](..$params) extends { ..$earlydefns } with ..$parents { $self => ..$body }"
            if mods.hasFlag(Flag.CASE) =>

          if (params.isEmpty) {
            abort(s"@builder can only be applied to case classes with at least one parameter")
          }
  
  ...

Generate type parameters for the builder

val fields: List[(TermName, Tree)] =
  params.collect {
    case q"$mods val $name: $tpt = $default" => (name.asInstanceOf[TermName], tpt)
    case q"$mods val $name: $tpt"            => (name.asInstanceOf[TermName], tpt)
  }

val typeParams: List[TypeDef] =
  fields.map { case (name, tpt) =>
    TypeDef(
      Modifiers(Flag.PARAM),
      TypeName(name.toString.capitalize),
      List(),
      TypeBoundsTree(EmptyTree, tq"Option[$tpt]")
    )
  }

This will help us build the following:

case class PersonBuilder[
  N <: Option[String],
  A <: Option[Int],
  E <: Option[String]
] ...

Generate builder constructor parameters

Next, we generate the constructor parameters for the builder class.

val builderParams: List[ValDef] =
  fields.map { case (name, _) =>
    val typeParamName = TypeName(name.toString.capitalize)
    val paramName     = TermName(s"${name}Opt")
    q"private val $paramName: $typeParamName"
  }

This will generate the following:

case class PersonBuilder[...] private (
  private[builder] val nameOpt: N,
  private[builder] val ageOpt: A,
  private[builder] val emailOpt: E
) 

Generate private setter methods

Next, we generate the private setter methods for the builder class with the function arguments based off the private fields.

val setterMethods: List[DefDef] =
fields.zipWithIndex.map { case ((name, tpt), idx) =>
  val setterName = TermName(s"set${name.toString.capitalize}")

  // Build the return type with Some[...] for this field and preserve others
  val returnTypeParams =
    fields.zipWithIndex.map { case ((_, _), otherIdx) =>
      if (otherIdx == idx) tq"Some[$tpt]"
      else {
        val otherTypeParamName = TypeName(fields(otherIdx)._1.toString.capitalize)
        tq"$otherTypeParamName"
      }
    }

  // Build constructor arguments - use Some for this field, keep others
  val constructorArgs: List[Tree] =
    fields.zipWithIndex.map { case ((otherName, _), otherIdx) =>
      val otherParamName = TermName(s"${otherName}Opt")
      if (otherIdx == idx) q"Some($name)" else q"$otherParamName"
    }

  q"""
  private def $setterName($name: $tpt): $builderName[..$returnTypeParams] =
    new $builderName[..$returnTypeParams](..$constructorArgs)
  """
}

Generate implicit helper classes

Next, we update our macro code to generate the implicit helper classes for the builder.

implicit final class NameOps[L <: Option[String], E <: Option[String]](
    private val builder: PersonBuilder[None.type, L, E]
  ) extends AnyVal { ... }

...

implicit final class BuildDoneOps(
  private val builder: PersonBuilder[Some[String], Some[String], Some[String]]
) extends AnyVal {
  def build(): Person =
    Person(
      builder.nameOpt.value,
      builder.ageOpt.value,
      builder.emailOpt.value
    )
}

I am leaving out the macro here for brevity. You can read the full file attached at the end of this post.

Generate the companion object’s apply method

Finally, we generate the companion object’s apply method.

val builderName       = TypeName(s"${className}Builder")
val builderObjectName = TermName(s"${className}Builder")

val noneTypes  = fields.map(_ => tq"None.type")
val noneValues = fields.map(_ => q"None")

val builderCompanion = q"""
  object $builderObjectName {
    def apply(): $builderName[..$noneTypes] =
      new $builderName[..$noneTypes](..$noneValues)
    
    ..$implicitClasses
    
    $buildDoneOps
  }
"""

This lets us instantiate the builder object for our Person class (via the builder helper in the Person’s companion object)

Person
  .PersonBuilder()
  .name("John")
  .age(30)
  .email("[email protected]")
  .build()

The builder macro

You can read the full file here: BuilderMacro.scala.

While the macro code is equally cryptic as the handwritten code, you write it only once, and for all, and above all, it is hidden from human eyes. But does the work behind the scenes.

The macro generates the builder code that provides the following advantages, similar to a handwritten one:

  • Compile-time guarantees that all required fields are set.
  • Allow setting fields in any order. More macro work is required if you want to enforce a specific order.
  • Forbid setting the same field twice.
  • Nice, readable code on the call site.

Photo courtesy: Dave H

scala builder macro