We would like to implement a form of dictionary (or map) that is immutable and remembers all the types of it's initialized values.
There is something very similar currently in Scala 3: the Selectable trait with Refinement types, but there are a couple of issues:
- The selectable trait lacks an implementation and it helps only with the access of values
- When you create a Selectable implementation, you have to cast each created object to a refinement matching it's internal map
- There is no type inference when creating them, you would have to declare all types each time
- The syntax for creating key-value Selectables involves one of two ways, none of which resemble the syntax of refinement types:
a) A list of tuples akin toobj("key" -> value)
b) A dynamic object with anapplyDynamicNamed
resulting inobj(key = value)
We could make our own Dict
type whose creation resembles refinement type declarations
This is how we want our client code to look like:
// mike is automatically type-inferred by the macro as Dict { val name: String, val age: Int, val likesMacros: Boolean }
val mike = Dict {
val name = "Mike"
val age = 28
val likesMacros = true
}
println(mike.name) // Works fine, type resolves to String
println(mike.wallet) // Error at compile-time, mike has no 'wallet', it is known
// Notice how this type's declaration resembles the variable mike's initialization almost perfectly
type Programmer = Dict {
val name: String
val age: Int
val likesMacros: Boolean
}
val mikeProgrammer: Programmer = mike // Works
type OfficeBuilding = Dict {
val address: String
val stories: Int
}
val mikeOfficeBuilding: OfficeBuilding = mike // Error at compile-time, mike doesn't have the fields of an OfficeBuilding, it is known
And here is to show that the Metals extension for VS Code recognizes our macro's inferred type: And that accessing also knows the type
Pretty cool right? If you didn't know, you would have thought this is default language syntax (but it's not, let's see how to make it happen)
Could be a good idea to look over the previous examples in this repo
The Selectable
trait in Scala 3 and how it works, Link to documentation
Most entities in a Quotes context have unapply methods that make matching very easy
Structure of entities used in this example:
The ValDef resembles ValDef(name: String, tpt: TypeTree, rhs: Option[Term]
, is a type of Definition
where:
name
is the name of the declared variabletpt
is theTypeTree
of the declaration,TypeTree
can be turned into aTypeRepr
withtpt.tpe
rhs
is the right-hand side term of the declaration, if it is an abstract val, this isNone
The Refinement TypeRepr resembles Refinement(parent: TypeRepr, name: String, info: TypeRepr)
where:
parent
is the predecesor of this type, Refinement types act like a linked list, each refinement object represents only oneval
definition and it's type, to have a refinement of multiple definitions, you need to nest them using theparent
field, the last refinement should point to a baseTypeRepr
name
is the name of this refinement's val definitioninfo
is the type representation of this refinement's val definition
The Type
type is a Quotes-agnostic type that represents type-level entities like String
or List[Int]
or "Hello" | "World"
.
You can obtain these by accessing (tpe: TypeRepr).asType
and you can use them in injected expressions by doing a match on them:
val typeReprOfString = TypeRepr.of[String] // This is an example, but this could be gotten from reflection, unknown here
val myExpr = typeReprOfString.asType match {
case '[myType] => ${
summon[myType]
val list = List.empty[myType]
// ...etc, it is a type-level name in here, the match is exhaustive
}
}
final class Dict private(val unsafeRawMap: Map[String, ?]) extends Selectable {
// Must be transparent and must have inline for the key parameter
transparent inline def selectDynamic(inline key: String) =
unsafeRawMap(key)
}
object Dict {
def fromUnsafeMap(map: Map[String, ?]): Dict =
new Dict(map)
// Must be transparent and have an inline Unit block
transparent inline def apply(inline block: => Unit) =
DictMacross.buildFromBlock(block)
}
import quoted.*
object DictMacross {
transparent inline def buildFromBlock(inline block: => Unit): Dict =
${ buildFromBlockImpl('block) }
def buildFromBlockImpl(block: Expr[Unit])(using Quotes): Expr[Dict] = {
import quotes.reflect.*
def getTypedKeysAndValues(list: List[Statement]): List[(String, TypeTree, Term)] = {
list match {
case Nil =>
Nil
case ValDef(name, typeTree, Some(term)) :: tail =>
(name, typeTree, term) :: getTypedKeysAndValues(tail)
case wrongHead :: tail =>
report.errorAndAbort(s"All statements in a Dict initializer must be initialized val definitions. Found: ${wrongHead.show}")
}
}
val info = block.asTerm.underlying match {
case Block(statements, _) =>
getTypedKeysAndValues(statements)
case _ => {
report.errorAndAbort("Parameter must be a block with only val definitions and no return")
}
}
val dictType = info
.foldLeft(TypeRepr.of[Dict])
.apply { case (acc, (name, tpt, _)) =>
Refinement(
parent = acc,
name = name,
info = tpt.tpe
)
}
val exprTuples = info
.map { case (name, _, term) =>
'{
(${Expr(name)}, ${term.asExpr})
}
}
dictType.asType match {
case '[fullDictType] =>
'{ Dict.fromUnsafeMap(${Expr.ofList(exprTuples)}.toMap).asInstanceOf[Dict & fullDictType] }
}
}
}
// Our Selectable implementation class, it has a raw map of it's objects and a private constructor
final class Dict private(val unsafeRawMap: Map[String, ?]) extends Selectable {
// Must be transparent and must have inline for the key parameter
// This method will be called as `selectDynamic("thing")` when we use `myDict.thing`
transparent inline def selectDynamic(inline key: String) =
unsafeRawMap(key)
}
object Dict {
// Our creation function must be exposed for macros to use it
def fromUnsafeMap(map: Map[String, ?]): Dict =
new Dict(map)
// Must be transparent and have an inline Unit block
// This function's parameter is a simple Unit block {...}, we will read it's statements one by one in the macro
transparent inline def apply(inline block: => Unit) =
DictMacross.buildFromBlock(block)
}
import quoted.*
object DictMacross {
transparent inline def buildFromBlock(inline block: => Unit): Dict =
${ buildFromBlockImpl('block) }
def buildFromBlockImpl(block: Expr[Unit])(using Quotes): Expr[Dict] = {
import quotes.reflect.*
// Utility function, given the list of Statements in our Block
// We extract only the statements that are ValDefs, recursively
// Yes, this could have been a tailrec function, but I like classic recursion better
// And if you make a type of 10,000 fields, stack overflows are the least of your concerns :')
def getTypedKeysAndValues(list: List[Statement]): List[(String, TypeTree, Term)] = {
list match {
case Nil =>
// Base case, empty to empty
Nil
case ValDef(name, typeTree, Some(term)) :: tail =>
// General case, ValDef builds as head yields a tuple added to the recursive list of the tail
(name, typeTree, term) :: getTypedKeysAndValues(tail)
case wrongHead :: tail =>
// If at any point we find a statement that is not a ValDef, report the error and abort
report.errorAndAbort(s"All statements in a Dict initializer must be initialized val definitions. Found: ${wrongHead.show}")
}
}
// block is an Expr[Unit] right now, so to read its contents, we use asTerm to turn it into a Term
// term.underlying lets us access the Term's core defintion, setting aside any synthetic wrappers such as Inlined or Ident
val info = block.asTerm.underlying match {
case Block(statements, _) =>
getTypedKeysAndValues(statements)
case _ => {
report.errorAndAbort("Parameter must be a block with only val definitions and no return")
}
}
// Here, we build the final result type artificially, we will cast our final result to this
// Casting in macros is usually a good idea if you know for certain the result type will make sense
// and issue compile-time errors otherwise
val dictType = info
.foldLeft(TypeRepr.of[Dict]) // The base type of our refinement
.apply { case (acc, (name, tpt, _)) =>
// For each of our found ValDef types, we build yet another Refinement type wrapping the old one
Refinement(
parent = acc,
name = name,
info = tpt.tpe
)
}
// Here, we build the tuples that will end up in our Dict's runtime Map
// Just a Map[String, ?] built through expressions
// More info on Expr splices in previous examples in this repo
val exprTuples = info
.map { case (name, _, term) =>
'{
(${Expr(name)}, ${term.asExpr})
}
}
// Here, we use the match case on the computed Type of our Dict so we can cast it inside the Expr
// While the everything else is done at compile-time, the transformation from List to Map is done at runtime
// And the casting as well is done at runtime (but considering that refinement types are erased, this is fine)
dictType.asType match {
case '[fullDictType] =>
'{ Dict.fromUnsafeMap(${Expr.ofList(exprTuples)}.toMap).asInstanceOf[Dict & fullDictType] }
}
}
}
We have made a way to build Dicts that know what they contain at compile-time. This technically allows us to write Scala code like it was Typescript, with dynamically declared types and object initialization resembling type declarations, but with type safety because the compiler always keeps tracks of our "dynamic types"; is that a good idea? your call.
This is how you would do Dict merging:
extension [D1 <: Dict, D2 <: Dict](first: D1) def ++ (second: D2) =
Dict.fromUnsafeMap(first.unsafeRawMap ++ second.unsafeRawMap).asInstanceOf[D1 & D2]
It essentially allows you to do:
val personInfo = Dict {
val name = "Joy"
val age = 31
}
val programmerInfo = Dict {
val yearsOfExperience = 7
val favoriteLanguage = "Scala"
}
val joyTheProgrammer = personInfo ++ programmerInfo
println(joyTheProgrammer.age)
println(joyTheProgrammer.yearsOfExperience)
println(joyTheProgrammer.netWorth) // ==> Compiletime Error, no such field