Back-end
6 minute read

Reduce Boilerplate Code With Scala Macros and Quasiquotes

As Chief Architect and Java expert for a remote work company, Alain has led software development teams to impact thousands of users' work.

The Scala language offers developers the opportunity to write object-oriented and functional code in a clean and concise syntax (as compared to Java, for example). Case classes, higher-order functions, and type inference are some of the features that Scala developers can leverage to write code that’s easier to maintain and less error-prone.

Unfortunately, Scala code is not immune to boilerplate, and developers may struggle to find a way to refactor and reuse such code. For example, some libraries force developers to repeat themselves by calling an API for each subclass of a sealed class.

But that’s only true until developers learn how to leverage macros and quasiquotes to generate the repeated code at compile time.

Use Case: Registering the Same Handler for All Subtypes of a Parent Class

During the development of a microservices system, I wanted to register a single handler for all events derived from a certain class. To avoid distracting us with the specifics of the framework I was using, here’s a simplified definition of its API for registering event handlers:

trait EventProcessor[Event] {
  def addHandler[E <: Event: ClassTag](
      handler: E => Unit
  ): EventProcessor[Event]

  def process(event: Event)
}

Having an event processor for any Event type, we can register handlers for subclasses of Event with the addHandler method.

Looking at the above signature, a developer might expect a handler registered for a given type to be invoked for events of its subtypes. For example, let’s consider the following class hierarchy of events involved in the User entity lifecycle:

Hierarchy of Scala events descending from UserEvent. There are three direct descendants: UserCreated (having a name and email, which are both Strings), UserChanged, and UserDeleted. Furthermore, UserChanged has two descendants of its own: NameChanged (having a name, which is a string) and EmailChanged (having an email, which is a string).
A Scala event class hierarchy.

The corresponding Scala declarations look like this:

sealed trait UserEvent
final case class UserCreated(name: String, email: String) extends UserEvent
sealed trait UserChanged                                  extends UserEvent
final case class NameChanged(name: String)                extends UserChanged
final case class EmailChanged(email: String)              extends UserChanged
case object UserDeleted                                   extends UserEvent

We can register a handler for each specific event class. But what if we want to register a handler for all the event classes? My first attempt was to register the handler for the UserEvent class. I expected it to be invoked for all the events.

val handler   = new EventHandlerImpl[UserEvent]
val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)

I noticed that the handler was never invoked during the tests. I dug into the code of Lagom, the framework I was using.

I found that the event processor implementation stored the handlers in a map with the registered class as the key. When an event is emitted, it looks for its class in that map to get the handler to call. The event processor is implemented along these lines:

type Handler[Event] = (_ <: Event) => Unit

private case class EventProcessorImpl[Event](
    handlers: Map[Class[_ <: Event], List[Handler[Event]]] =
      Map[Class[_ <: Event], List[Handler[Event]]]()
) extends EventProcessor[Event] {

  override def addHandler[E <: Event: ClassTag](
      handler: E => Unit
  ): EventProcessor[Event] = {
    val eventClass =
      implicitly[ClassTag[E]].runtimeClass.asInstanceOf[Class[_ <: Event]]
    val eventHandlers = handler
      .asInstanceOf[Handler[Event]] :: handlers.getOrElse(eventClass, List())
    copy(handlers + (eventClass -> eventHandlers))
  }

  override def process(event: Event): Unit = {
    handlers
      .get(event.getClass)
      .foreach(_.foreach(_.asInstanceOf[Event => Unit].apply(event)))
  }
}

Above, we registered a handler for the UserEvent class, but whenever a derived event like UserCreated was emitted, the processor wouldn’t find its class in the registry.

Thus Begins the Boilerplate Code

The solution is to register the same handler for each concrete event class. We can do it like this:

val handler = new EventHandlerImpl[UserEvent]  
val processor = EventProcessor[UserEvent]  
  .addHandler[UserCreated](handler)  
  .addHandler[NameChanged](handler)  
  .addHandler[EmailChanged](handler)  
  .addHandler[UserDeleted.type](handler)

Now the code works! But it’s repetitive.

It’s also difficult to maintain, as we will need to modify it every time we introduce a new event type. We might also have other places in our codebase where we are forced to list all the concrete types. We would also need to make sure to modify those places.

This is disappointing, as UserEvent is a sealed class, meaning that all its direct subclasses are known at compile time. What if we could leverage that information to avoid boilerplate?

Macros to the Rescue

Normally, Scala functions return a value based on the parameters we pass to them at run time. You can think of Scala macros as special functions that generate some code at compile time to replace their invocations with.

While the macro interface might seem to take values as parameters, its implementation will actually capture the abstract syntax tree (AST)—the internal representation of source code structure that the compiler uses—of those parameters. It then uses the AST to generate a new AST. Finally, the new AST replaces the macro call at compile time.

Let’s look at a macro declaration that will generate event handler registration for all the known subclasses of a given class:

def addHandlers[Event](
      processor: EventProcessor[Event],
      handler: Event => Unit
  ): EventProcessor[Event] = macro setEventHandlers_impl[Event]  
  
  
def setEventHandlers_impl[Event: c.WeakTypeTag](c: Context)(
      processor: c.Expr[EventProcessor[Event]],
      handler: c.Expr[Event => Unit]
  ): c.Expr[EventProcessor[Event]] = {

  // implementation here
}

Notice that for each parameter (including type parameter and return type), the implementation method has a corresponding AST expression as a parameter. For example, c.Expr[EventProcessor[Event]] matches EventProcessor[Event]. The parameter c: Context wraps the compilation context. We can use it to get all the information available at compile time.

In our case, we want to retrieve the children of our sealed class:

import c.universe._  
  
val symbol = weakTypeOf[Event].typeSymbol

def subclasses(symbol: Symbol): List[Symbol] = {  
  val children = symbol.asClass.knownDirectSubclasses.toList  
  symbol :: children.flatMap(subclasses(_))  
}  
  
val children = subclasses(symbol)

Note the recursive call to the subclasses method to ensure that indirect subclasses are also processed.

Now that we have the list of the event classes to register, we can build the AST for the code that the Scala macro will generate.

Generating Scala Code: ASTs or Quasiquotes?

To build our AST, we can either manipulate AST classes or use Scala quasiquotes. Using AST classes can produce code that is difficult to read and maintain. In contrast, quasiquotes dramatically reduce the complexity of the code by allowing us to use a syntax that is very similar to the generated code.

To illustrate the simplicity gain, let’s take the simple expression a + 2. Generating this with AST classes looks like this:

val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))

We can achieve the same with quasiquotes with a more concise and readable syntax:

val exp = q"a + 2"

To keep our macro straightforward, we’ll use quasiquotes.

Let’s create the AST and return it as the result of the macro function:

val calls = children.foldLeft(q"$processor")((current, ref) =>
  q"$current.addHandler[$ref]($handler)"
)
c.Expr[EventProcessor[Event]](calls)

The code above starts with the processor expression received as a parameter, and for each Event subclass, it generates a call to the addHandler method with the subclass and handler function as parameters.

Now we can call the macro on the UserEvent class and it will generate the code to register the handler for all the subclasses:

val handler = new EventHandlerImpl[UserEvent]  
val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)

That will generate this code:

com.example.event.processor.EventProcessor
.apply[com.example.event.handler.UserEvent]()
.addHandler[UserEvent](handler)
.addHandler[UserCreated](handler)
.addHandler[UserChanged](handler)
.addHandler[NameChanged](handler)
.addHandler[EmailChanged](handler)
.addHandler[UserDeleted](handler)

The code of the complete project compiles correctly and the test cases demonstrate that the handler is indeed registered for each subclass of UserEvent. Now we can be more confident in the capacity of our code to handle new event types.

Repetitive Code? Get Scala Macros to Write It

Even though Scala has a concise syntax that usually helps to avoid boilerplate, developers can still find situations where code becomes repetitive and cannot be easily refactored for reuse. Scala macros can be used with quasiquotes to overcome such issues, keeping Scala code clean and maintainable.

There are also popular libraries, like Macwire, that leverage Scala macros to help developers generate code. I strongly encourage every Scala developer to learn more about this language feature, as it can be a valuable asset in your tool set.

Understanding the basics

What is Scala used for?

Scala lets programmers write object-oriented and functional code in a clean and concise syntax. Its output can be executed either in a Java virtual machine (JVM) or in a JavaScript runtime.

What are Scala macros?

Scala macros are special functions that replace their own invocations with source code that they generate at compile time.

What are syntax trees?

An abstract syntax tree (or AST) is the representation of source code structure that a compiler uses internally.

What are sealed classes?

Sealed classes are classes whose subclasses are known at compile time. In Scala, they cannot be extended by classes outside of the source file in which they are defined.

What are classes and objects in Scala?

In Scala, classes define common structure and behavior; objects are instances of a class. Scala also uses the object keyword to define singletons (classes with a single instance).