Scala 3 Macros: How to Read Annotations :: Jun 25, 2023

This post will examine how to read class & field StaticAnnotations from a Scala 3 Macro.

Types of Annotations

Scala has a few different types of annotations. The base class of all annotations is Annotation, but developers should extend one of the subtypes. StaticAnnotations are persisted to the class file, and can be read from macros. ConstantAnnotation extends StaticAnnotation and requires all parameters to be compile-time constants. Finally, MacroAnnotation is an experimental annotation that can transform & create new definitions.

Reading Annotations on a Class

Assume we have the following SqlName StaticAnnotation, applied to a class:

class SqlName(val sqlName: String) extends StaticAnnotation

@SqlName("app_user")
case class AppUser(
  id: Long,
  firstName: Option[String],
  lastName: String
)

We can read the sqlName value with the following macro:

import scala.compiletime.*
import scala.quoted.*

inline def sqlNameFor[T]: Option[String] = ${ sqlNameForImpl[T] } 

private def sqlNameForImpl[T: Type](using Quotes): Expr[Option[String]] =
  import quotes.reflect.*
  val annot = TypeRepr.of[SqlName]
  TypeRepr
    .of[T]
    .typeSymbol
    .annotations
    .collectFirst:
      case term if term.tpe =:= annot => term.asExprOf[SqlName]
  match
    case Some(expr) => '{ Some($expr.sqlName) }
    case None       => '{ None }

In a different file:

@main def main: Unit =
  println(sqlNameFor[AppUser]) // Some(app_user)

Reading Annotations on a Field

Let’s take the same SqlName annotation as above, and apply it on a field:

class SqlName(val sqlName: String) extends StaticAnnotation

case class AppUser(
  id: Long,
  firstName: Option[String],
  @SqlName("last_name") lastName: String
)

We can read the sqlName value with the following macro:

inline def sqlFieldNamesFor[T]: Vector[(String, String)] = ${
  sqlFieldNamesForImpl[T]
}

private def sqlFieldNamesForImpl[T: Type](using
    q: Quotes // must be named!!
): Expr[Vector[(String, String)]] =
  import quotes.reflect.*
  val annot = TypeRepr.of[SqlName].typeSymbol
  val tuples: Seq[Expr[(String, String)]] = TypeRepr
    .of[T]
    .typeSymbol
    .primaryConstructor
    .paramSymss
    .flatten
    .collect:
      case sym if sym.hasAnnotation(annot) =>
        val fieldNameExpr = Expr(sym.name.asInstanceOf[String])
        val annotExpr = sym.getAnnotation(annot).get.asExprOf[SqlName]
        '{ ($fieldNameExpr, $annotExpr.sqlName) }
  val seq: Expr[Seq[(String, String)]] = Expr.ofSeq(tuples)
  '{ $seq.toVector }

And in a different file,

@main def hello: Unit =
  println(sqlFieldNamesFor[AppUser]) // Vector((lastName,last_name))

It’s weird that the compiler requires the given Quotes to be named, and the error message is pretty cryptic. I’ve filed a Scala 3 bug: https://github.com/lampepfl/dotty/issues/18059