Search code examples
scalamacroscode-generationscalameta

Using code generation (like Scala Meta) to scrape boilerplate


I use Shapeless's tagged types to get nice typesafe primitives to pass through my business logic. Defining these types started with a simple:

sealed trait MyTaggedStringTag
type MyTaggedString = String @@ MyTaggedStringTag

But I've added a good bit of helper logic to this, and now my definitions look more like:

sealed trait MyTaggedStringTag

type MyTaggedString = String @@ MyTaggedStringTag
object MyTaggedString {
  def fromString(untaggedMyTaggedString: String): MyTaggedString = {
    val myTaggedString = tag[MyTaggedStringTag](untaggedMyTaggedString)
    myTaggedString
  }
}
implicit class MyTaggedStringOps(val myTaggedString: MyTaggedString) extends AnyVal { def untagged = myTaggedString.asInstanceOf[String] }

So, it's a lot of boilerplate per definition. I'd really like to be able to generate this by doing something like:

@tagged[String] type MyTaggedString

Is there a way to do something like this with Scala Meta, or some other code generation tool?


Solution

  • Updated

    This is now fully working and can be seen in a new library I call Taggy. Here is the latest version of the macro:

    class tagged extends scala.annotation.StaticAnnotation {
      inline def apply(defn: Any): Any = meta {
        // Macro annotation type and value parameters come back as AST data, not 
        // values, and are accessed by destructuring `this`.
        defn match {
          case q"..$mods type $newType = ${underlyingType: Type.Name}" => 
            TaggedImpl.expand(underlyingType, newType, mods)
          case _ => 
            abort("Correct usage: @tagged type NewType = UnderlyingType" )
        }
      }
    }
    
    object TaggedImpl {
      def expand(underlyingType: Type.Name, newType: Type.Name, mods: Seq[Mod]) = {
        // Shapeless needs a phantom type to join with the underlying type to
        // create our tagged type. Ideally should never leak to external code.
        val tag = Type.Name(newType.value + "Tag")
    
        // The `fromX` helper will go in the companion object.
        val companionObject = Term.Name(newType.value)
    
        // We'll name the `fromX` method based on the underlying type.
        val fromMethod = Term.Name("from" + underlyingType.value)
    
        // The `untagged` helper goes in an implicit class, since the tagged type
        // is only a type alias, and can't have real methods. 
        val opsClass = Type.Name(newType.value + "Ops")
    
        q"""
          sealed trait $tag
          ..$mods type $newType = com.acjay.taggy.tag.@@[$underlyingType, $tag]
          ..$mods object $companionObject {
            def $fromMethod(untagged: $underlyingType): $newType = {
              val tagged = com.acjay.taggy.tag[$tag](untagged)
              tagged
            }
          }
          ..$mods implicit class $opsClass(val tagged: $newType) extends AnyVal { 
            def untagged = tagged.asInstanceOf[$underlyingType]
            def modify(f: $underlyingType => $underlyingType) = $companionObject.$fromMethod(f(untagged))
          }
        """
      }
    }
    
    object tag {
      def apply[U] = new Tagger[U]
    
      trait Tagged[U]
      type @@[+T, U] = T with Tagged[U]
    
      class Tagger[U] {
        def apply[T](t : T) : T @@ U = t.asInstanceOf[T @@ U]
      }
    }
    

    The parsing of the macro syntax and code generation are separated for readability. You could inline TaggedImpl.expand into the meta block. Also note that the syntax here is now @tagged type MyTaggedString = String.

    Original answer

    I got it working as a proof of concept. But it takes the string name of the underlying type:

    import scala.meta._
    
    class tagged(_underlyingTypeName: String) extends scala.annotation.StaticAnnotation {
      inline def apply(defn: Any): Any = meta {
        // Can't figure out how to do this extraction as a quasiquote, so I 
        // figured out exactly the AST `this` produces to extract the string 
        // parameter.
        val Term.New(
          Template(
            List(),
            List(Term.Apply(Ctor.Ref.Name("tagged"), List(Lit.String(underlyingTypeName)))),
            Term.Param(List(), Name.Anonymous(), None, None),
            None
          )
        ) = this
    
        val q"..$mods type $tname[..$tparams]" = defn
        val underlyingType = Type.Name(underlyingTypeName)
        TaggedImpl.expand(tname, underlyingType)
      }
    }
    
    object TaggedImpl {
      def expand(taggedType: Type.Name, underlyingType: Type.Name) = {
        val tag = Type.Name(taggedType.value + "Tag")
        val companionObject = Term.Name(taggedType.value)
        val fromMethodName = Term.Name("from" + underlyingType.value)
        val opsClass = Type.Name(taggedType.value + "Ops")
    
        q"""
          sealed trait $tag
          type $taggedType = shapeless.tag.@@[$underlyingType, $tag]
          object $companionObject {
            def $fromMethodName(untagged: $underlyingType): $taggedType = {
              val tagged = shapeless.tag[$tag](untagged)
              tagged
            }
          }
          implicit class $opsClass(val tagged: $taggedType) extends AnyVal { 
            def untagged = tagged.asInstanceOf[$underlyingType] 
          }
        """
      }
    }