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]
}