Reduce Boilerplate Code With Scala Macros and Quasiquotes
The concise syntax of Scala usually helps developers avoid writing boilerplate. When repetitive code is required anyway, developers can use macros and quasiquotes to keep code clean and maintainable. Here’s how.
The concise syntax of Scala usually helps developers avoid writing boilerplate. When repetitive code is required anyway, developers can use macros and quasiquotes to keep code clean and maintainable. Here’s how.
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:
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.
Further Reading on the Toptal Blog:
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).
Abidjan, Lagunes Region, Côte D'Ivoire
Member since November 11, 2020
About the author
As Chief Architect and Java expert for a remote work company, Alain has led software development teams to impact thousands of users’ work.