Scala 3 Macros: Create Types with Arbitrary Values & Methods :: Jun 17, 2023
Sometimes you want to make types whose values & methods are determined programmatically, at compile time.
An example usecase is a Builder
for arbitrary case classes:
case class User(firstName: String, age: Int)
val userBuilder = Builder[User]
.withFirstName("Athena")
.withAge(22)
println(userBuilder.age) // 22
println(userBuilder.build) // User("Athena", 22)
Note that autocomplete is somewhat working in Metals:
And that the type of userBuilder
is
Builder[User] {
val firstName: java.lang.String
def withFirstName(value: java.lang.String): this
val age: scala.Int
def withAge(value: scala.Int): this
}
Tools of the Trade
One way to implement the Builder
would be with Macro Annotations, which are an experimental feature introduced in Scala 3.3.
But this is not required. Programatic Structural Types already let us refine types with new vals and defs. Then we just need a macro to decide the names of the vals & defs. The macro must also be transparent in order to return the programmatic type.
Implementing the Builder class
As with other Structural Types, our Builder class must implement Selectable.
class Builder[T](
mirror: Mirror.ProductOf[T],
getters: IArray[String],
setters: IArray[String]
) extends Selectable:
private val values = Array.ofDim[Object](getters.length)
def selectDynamic(name: String): Any =
values(getters.indexOf(name))
def applyDynamic(name: String)(args: Any*): this.type =
values(setters.indexOf(name)) = args.head.asInstanceOf[Object]
this
def build: T = mirror.fromProduct(Tuple.fromArray(values))
When we call Builder[User].age
, this is translated by the compiler into Builder[User].selectDynamic("age")
. Likewise, Builder[User].withAge(22)
becomes Builder[User].applyDynamic("withAge")(22)
. Finally, build
uses a Mirror to instantiate the class.
For Builder[User]
, the getters
array must be IArray("firstName", "age")
, and setters
must be IArray("withFirstName", "withAge")
.
The Builder macro
Our macro is defined as the apply
method in Builder’s companion object:
import scala.deriving.*
import scala.compiletime.*
import scala.quoted.*
object Builder:
transparent inline def apply[T <: Product] = ${ builderImpl[T] }
builderImpl
summons the product mirror:
private def builderImpl[T: Type](using Quotes): Expr[Any] =
Expr.summon[Mirror.ProductOf[T]].get match
case '{
$m: Mirror.ProductOf[T] {
type MirroredElemLabels = mels
type MirroredElemTypes = mets
}
} =>
refineBuilder[T, mets, mels, Builder[T]](m)
refineBuilder
is the meat and potatoes. It recursively loops over T’s element types & labels, constructing the getter and setter arrays. Refinement is used to construct the structural refinement on Builder[T]. For the getter, this refinement is a simple val (Refinement(TypeRepr.of[Res], getter, TypeRepr.of[met])
). But setters are harder, since they are Methods returning this.type
.
private def refineBuilder[T: Type, Mets: Type, Mels: Type, Res: Type](
m: Expr[Mirror.ProductOf[T]],
getters: IArray[String] = IArray.empty,
setters: IArray[String] = IArray.empty
)(using Quotes): Expr[Any] =
import quotes.reflect.*
(Type.of[Mets], Type.of[Mels]) match
case ('[met *: metTail], '[mel *: melTail]) =>
val getter: String = Type.valueOfConstant[mel].get.toString
val setter = "with" + getter.head.toUpper + getter.substring(1)
val getterRefinement =
Refinement(TypeRepr.of[Res], getter, TypeRepr.of[met])
val setterRefinement = RecursiveType: parent =>
val mt =
MethodType(List("value"))(
_ => List(TypeRepr.of[met]),
_ => parent.recThis
)
Refinement(getterRefinement, setter, mt)
setterRefinement.asType match
case '[tpe] =>
refineBuilder[T, metTail, melTail, tpe](
m,
getters :+ getter,
setters :+ setter
)
case ('[EmptyTuple], '[EmptyTuple]) =>
'{
new Builder[T](
$m,
${ Expr(getters) },
${ Expr(setters) }
).asInstanceOf[Res]
}
Error Messages
Because the setter methods return this.type
, compiling Builder[User].wrongMethodName(22)
makes scalac stack overflow. Hopefully there’s a better way to encode the refinement with RecursiveType.
Conclusions
Scala 3 meta-programming is a pleasure to use. Macros + structural types allow many possibilities, but should only be used when all other methods are exhausted, since the IDE support and error messages are not as good as the alternatives.
Edit: It appears that returning this
in a structural type is deprecated: https://github.com/lampepfl/dotty/discussions/14056#discussioncomment-6238099
Final Code
import scala.deriving.*
import scala.compiletime.*
import scala.quoted.*
import scala.collection.mutable as m
import scala.reflect.{ClassTag, classTag}
class Builder[T](
mirror: Mirror.ProductOf[T],
getters: IArray[String],
setters: IArray[String]
) extends Selectable:
private val values = Array.ofDim[Object](getters.length)
def selectDynamic(name: String): Any =
values(getters.indexOf(name))
def applyDynamic(name: String)(args: Any*): this.type =
values(setters.indexOf(name)) = args.head.asInstanceOf[Object]
this
def build: T = mirror.fromProduct(Tuple.fromArray(values))
object Builder:
transparent inline def apply[T <: Product] = ${ builderImpl[T] }
private def builderImpl[T: Type](using Quotes): Expr[Any] =
Expr.summon[Mirror.ProductOf[T]].get match
case '{
$m: Mirror.ProductOf[T] {
type MirroredElemLabels = mels
type MirroredElemTypes = mets
}
} =>
refineBuilder[T, mets, mels, Builder[T]](m)
private def refineBuilder[T: Type, Mets: Type, Mels: Type, Res: Type](
m: Expr[Mirror.ProductOf[T]],
getters: IArray[String] = IArray.empty,
setters: IArray[String] = IArray.empty
)(using Quotes): Expr[Any] =
import quotes.reflect.*
(Type.of[Mets], Type.of[Mels]) match
case ('[met *: metTail], '[mel *: melTail]) =>
val getter: String = Type.valueOfConstant[mel].get.toString
val setter = "with" + getter.head.toUpper + getter.substring(1)
val getterRefinement =
Refinement(TypeRepr.of[Res], getter, TypeRepr.of[met])
val setterRefinement = RecursiveType: parent =>
val mt =
MethodType(List("value"))(
_ => List(TypeRepr.of[met]),
_ => parent.recThis
)
Refinement(getterRefinement, setter, mt)
setterRefinement.asType match
case '[tpe] =>
refineBuilder[T, metTail, melTail, tpe](
m,
getters :+ getter,
setters :+ setter
)
case ('[EmptyTuple], '[EmptyTuple]) =>
'{
new Builder[T](
$m,
${ Expr(getters) },
${ Expr(setters) }
).asInstanceOf[Res]
}