Eureka! After (literal) years of experimentation—though, luckily for my psyche, extremely intermittent experimentation—I’ve stumbled upon a solution* to this problem. *At least, a solution for my particular problem subspace.
The Problem
I’ve been pining for one specific aspect of Scala 2’s meta-programming: The ability to outfit a companion object with a set of methods, loosely derived from the structure of the underlying trait or case class.
I’m recalling the anguish of writing Lenses in longhand:
case class Person(name: String, age: Int)
object Person:
val name = Lens[Person](_.name)(p => name => p.copy(name = name))
val age = Lens[Person](_.age)(p => age => p.copy(age = age))
Whereas, in Scala 2, we had:
@deriveLenses
case class Person(name: String, age: Int)
Person.name // Lens[Person, String]
Person.age // Lens[Person, Int]
Similarly, a common pattern of boilerplate besmirching many a ZIO
codebase is that of “accessors”:
trait ExampleService:
def add(x: Int, y: Int): Task[Int]
object ExampleService:
def add(x: Int, y: Int): ZIO[ExampleService, Throwable, Int] =
ZIO.serviceWithZIO(_.add(x, y))
So much needless RSI, when we simply could’ve written:
@deriveAccessors
trait ExampleService:
def add(x: Int, y: Int): Task[Int]
Solution
I’ve taken five or six abortive stabs at this problem throughout the years. The closest I’d found previously is the Selectable pattern. The pattern, described in this issue, didn’t work at first, due to the lack of autocomplete support—hence the issue, which has since been addressed .
So, until now, the best I’d had was this (copy-pasted from the issue, so ignore the commented caveat):
case class Person(name: String, age: Int)
object Person {
val lenses = Lenses.gen[Person]
}
Person.lenses.name // For this to be tenable, this would need to autocomplete with the type Lens[Person, String]
You know, this ain’t too bad. But that little gap of convenience, of needing to call through some intermediate Selectable value, has been gnawing at me. I wanted to call Person.name
or ExampleService.method
directly.
And so, I’ve finally concocted a way of doing this. I’m surprised it works at all, to be honest. It is, essentially, the daisy-chaining together of the Selectable
pattern with a Conversion
and a given macro
, allowing for arbitrary macro-generated extension methods. It’s a neat trick and I’m lucky to have found it, because there are about 12 subtle variations which all fail spectacularly. I was on the verge of giving up when it finally compiled.
With this trick in place, we get the following:
case class Person(name: String, age: Int, isAlive: Boolean)
object Person extends DeriveLenses
@main
def example(): Unit =
val person = Person("Alice", 42, true)
val name = Person.name.get(person)
val age = Person.age.get(person)
val isAlive = Person.isAlive.get(person)
println(s"Name: $name, Age: $age, Is Alive: $isAlive")
It’s still not perfect, as one must extend the Companion object, which means one must still define the companion object, even if it’s otherwise unnecessary. Yet, save for that blemish, this long sought after syntactic summit is finally reachable.
It works for the ZIO accessor pattern as well:
trait ExampleService:
def launchRockets(): Task[Unit]
def addNumbers(a: Int, b: Int): UIO[Int]
object ExampleService extends DeriveAccessors
object Example extends ZIOAppDefault:
val program: ZIO[ExampleService, Throwable, Unit] =
for
_ <- ExampleService.addNumbers(1, 2)
_ <- ExampleService.launchRockets()
yield ()
val run =
program.provide(ExampleServiceLive.layer)
The other downside, of course, is that the transparent inline def
s required to make this work, only truly work with Metals. So IDEA is decidedly uninvited to the party. I really hope this changes before long, but that’s a separate issue.
Code
The implementation of DeriveLenses
is here: quotidian/examples/shared/src/main/scala/quotidian/examples/lens/LensMacros.scala at main · kitlangton/quotidian · GitHub
As you can see, it’s a thin, yet necessary, wrapper around some other macro-generated bits.
trait DeriveLenses:
given conversion(
using
cc: CompanionClass[this.type],
lenses: LensesFor[cc.Out]
): Conversion[this.type, lenses.Out] =
_ => lenses.lenses
I hope the pattern can be simplified somewhat (open to suggestions!), but at least it’s nice and clean at the call-site.
Final Entreaty
Of course, what I’d really love is the reinstatement of this particular subset of annotation macros. It sure would be neat if they could once again extend the companion object with arbitrary helper methods.
Luckily, one can achieve the same effect with this combination of non-experimental Scala 3 macros + other mechanisms. Therefore, if anyone fears of the consequences of such a feature, well: Be afraid now! . The only issue is that we’re about 2% shy of syntactic perfection.
Anyhow, thanks for reading! I hope this was useful/entertaining/distracting-from-some-chronic-pain-now-reminded-of. And, just to end on a positive note: Any frustration I express, now or ever, over the Scala 3 macro system is born of pure joy and love. It’s been so fun messing around with (and trying to break) it over all these years. Endless gratitude to all who build and maintain it.