Lenses and optics

A Lens[M, V] is get + set on an immutable model. derives Optics plus Optics[M](_.field.nested) builds that lens at compile time from a field-selection path. No reflection. Unmodified sibling trees keep the same reference, which is what FastEq withReferenceEquality exploits.

Derive a path

case class Address(city: String, zip: String) derives Optics
case class User(name: String, age: Int, address: Address) derives Optics
case class Model(user: User, count: Int) derives Optics

val city = Optics[Model](_.user.address.city)

The path must be a chain of field selects. Method calls and match are compile errors.

{
  val city  = Optics[Model](_.user.address.city)
  val start = Model(User("Ada", 36, Address("NYC", "10001")), 0)
  val moved = city.set(start, "Boston")
  (city.get(start), city.get(moved), moved.user.name)
}
(NYC,Boston,Ada)
Mermoid.diagram(compose)

What the macro writes

Optics[Model](_.user.address.city) is the same copy chain you would type by hand:

new Lens[Model, String]:
  def get(m: Model) = m.user.address.city
  def set(m: Model, v: String) =
    m.copy(user = m.user.copy(address = m.user.address.copy(city = v)))

Compose

>> (also compose) turns Lens[A, B] and Lens[B, C] into Lens[A, C]. Nested Optics[M](_.a.b) is that composition, inlined.

{
  val user  = Optics[Model](_.user)
  val city  = Optics[User](_.address.city)
  val path  = user >> city
  val start = Model(User("Ada", 36, Address("NYC", "10001")), 0)
  path.get(start)
}
NYC
Mermoid.diagramInteractive(compose, initialWidth = 560)
viewport 560px

Laws

For a lawful lens: get-set is identity, set-get returns the written value, set-set keeps the last write. Collection helpers in Collection lenses are lawful at every defined index; out-of-range writes are no-ops.