'Whitebox' Macros in Scala 3 Are Possible After All?

All the documentation and stack overflow says that ‘Whitebox’ macros are impossible in Scala 3.

In fact, by making a transparent macro that returns a structural type you can create types with any vals and defs that you wish.

Here’s an example:

case class User(firstName: String, age: Int)

// has the type of Props { val firstName: String }
val userProps = props[User]

println(userProps.firstName) // prints "prop for firstName"
println(userProps.lastName) // compile error

Here’s the implementation:

import scala.compiletime.*
import scala.quoted.*
import scala.deriving.Mirror

class Props extends Selectable:
  def selectDynamic(name: String): Any =
    "prop for " + name

transparent inline def props[T] =
  ${ propsImpl[T] }

private def propsImpl[T: Type](using Quotes): Expr[Any] =
  import quotes.reflect.*

  Expr.summon[Mirror.ProductOf[T]].get match
    case '{ $m: Mirror.ProductOf[T] {type MirroredElemLabels = mels; type MirroredElemTypes = mets } } =>
      Type.of[mels] match
        case '[mel *: melTail] =>
          val label = Type.valueOfConstant[mel].get.toString

          Refinement(TypeRepr.of[Props], label, TypeRepr.of[String]).asType match
            case '[tpe] =>
              val res = '{
                val p = Props()
                p.asInstanceOf[tpe]
              }
              println(res.show)
              res

Many thanks to Guillaume Martres for help with getting the type tpe from Refinement… that was tricky. I looked at the generated code and can confirm reflection is only ever at compile time.

You could potentially use this approach for a wide variety of purposes, for example I made a SQL ‘specification’ helper:

  case class Car(id: Int|Null = null, model: String, topSpeed: Int)
  val cols = columns[Car]

  val spec = SpecBuilder()
    .where(cols.model.notEq("Lambo"))
    .where(cols.topSpeed.gt(100) or cols.model.eq("Corvette"))
    .orderBy(cols.topSpeed.desc)
    .offset(1)
    .build()

  findAll(spec).mkString(", ")

But will an IDE ever support auto-completion for this? I have no clue.

8 Likes

IMHO: There is a lack of “Nested Match Types”(analogy to Match Types) which will allow to change type of columns or hide ones.

Query extends Selectable ...
...
 result = new Query{
   val name= StringColumn("name_s")
   val price= NumberColumn("price_n")
 }.sql("select * from car").first()
 
 println(resutl.name)

Structural type seems a good glue with other languages. But without ability to transform a column type the way which can be understood by an ide it seems there are no way to create dsl without code duplication.

Yeah, I think the IDE story is the key reason why doing code-gen with an annotation processor will always be better than a whitebox macro.

Actually, the term “whitebox” does appear in the documentation.

Transparent inline methods are “whitebox” in the sense that the type of an application of such a method can be more specialized than its declared return type, depending on how the method expands.

6 Likes

After the Scala 3.2 release added ‘Code Completion for Refined Types’ (Scala 3.2.0 released! | The Scala Programming Language ), the auto-complete now works great in Metals

Unfortunately it does not work in IDEA yet.

I created an IDEA issue here https://youtrack.jetbrains.com/issue/SCL-20993/No-auto-complete-for-Scala-3-transparent-inline-method.

However, this macro is pretty complicated. And the the use case is very common in any data modeling domain.

@markehammons explains with more detail in Allow structural types to be completely generated from match types

If we could use match types & inline to define our own structural types, I bet 90% of macros in the wild could be eliminated.

3 Likes