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.