Generics

The cats-eo-generics module supplies two macros that eliminate the boilerplate usually written by hand for Lens / Prism derivation.

libraryDependencies += "dev.constructive" %% "cats-eo-generics" % "0.1"
import dev.constructive.eo.optics.Optic.*
import dev.constructive.eo.generics.{lens, prism}
import dev.constructive.eo.docs.{Address, Customer, NameAgePair, Person, Shape, Shape2, Coords, Zip}
Macro-derived optics need their target case classes and enum cases to live at a package-level location. The page hosts its samples in dev.constructive.eo.docs.* for that reason — the same lens / prism calls work identically on your own top-level ADTs.

lens[S](_.field)

Two-step partial application: lens[Customer] pins the source type, the second call picks the field:

val nameL = lens[Customer](_.name)
val ageL  = lens[Customer](_.age)
val alice = Customer("Alice", 30)
// alice: Customer = Customer(name = "Alice", age = 30)
nameL.get(alice)
// res0: String = "Alice"
ageL.replace(31)(alice)
// res1: Customer = Customer(name = "Alice", age = 31)
nameL.modify(_.toUpperCase)(alice)
// res2: Customer = Customer(name = "ALICE", age = 30)

Works on any N-field case class. The macro also handles Scala 3 enum cases, which would normally break under Monocle's GenLens because enum cases don't expose .copy. EO emits a direct new S(…) call through hearth's CaseClass.construct, which works uniformly for both.

Composition

Use .andThen to drill deeper:

val streetL =
  lens[Person](_.address).andThen(lens[Address](_.street))
val bob = Person("Bob", Address("Elm St", Zip(54321, "0000")))
// bob: Person = Person(
//   name = "Bob",
//   address = Address(
//     street = "Elm St",
//     zip = Zip(code = 54321, extension = "0000")
//   )
// )
streetL.get(bob)
// res3: String = "Elm St"
streetL.modify(_.toUpperCase)(bob)
// res4: Person = Person(
//   name = "Bob",
//   address = Address(
//     street = "ELM ST",
//     zip = Zip(code = 54321, extension = "0000")
//   )
// )

Type-level complement

The derived lens exposes the structural complement of the focused field as its existential X. For an N-field case class focused on one field, X is a NamedTuple over the remaining fields, preserving both names and types. That's the evidence Optic.transform / .place / .transfer need — no given at the call site required:

val renamed = nameL.place("Carol")(alice)
// renamed: Customer = Customer(name = "Carol", age = 30)

Multi-field Lens — lens[S](_.a, _.b, …)

The same entry point accepts multiple selectors. When the selector set is a strict subset of the case class's fields, the macro emits a SimpleLens[S, Focus, Complement] where Focus is a Scala 3 NamedTuple in SELECTOR order and Complement is a NamedTuple in DECLARATION order among the non-focused fields.

// `Customer(name, age)` with only 2 fields is full-cover territory —
// see the Iso section below. For a proper partial-cover example we
// need a wider case class; use `Person(name, address)` (2 fields) by
// focusing *one* field to stay on the Lens path, then reach for
// multi-field on wider data like the 3-field ADT below.

final case class OrderItem(sku: String, quantity: Int, price: Double)
val qtyAndPrice = lens[OrderItem](_.quantity, _.price)
val item = OrderItem("abc-123", 3, 9.99)
// item: OrderItem = OrderItem(sku = "abc-123", quantity = 3, price = 9.99)
val focus = qtyAndPrice.get(item)
// focus: NamedTuple[*:["quantity", *:["price", EmptyTuple]], *:[Int, *:[Double, EmptyTuple]]] = (
//   3,
//   9.99
// )
focus.quantity
// res5: Int = 3
focus.price
// res6: Double = 9.99

val (complement, _) = qtyAndPrice.to(item)
// complement: NamedTuple[*:["sku", EmptyTuple], *:[String, EmptyTuple]] = Tuple1(
//   "abc-123"
// )
complement.sku
// res7: String = "abc-123"

The focus NamedTuple preserves selector order, so lens[OrderItem](_.price, _.quantity) would produce a focus whose .price field comes before .quantity. That choice is deliberate (D1 in the implementation plan) — downstream code usually cares about the order the fields appear in the call, not the original declaration order.

Full-cover Iso — lens[S](_.a, _.b, …) covering every field

When the selector set covers every case field of S (in any order, at any arity including N = 1 on a 1-field wrapper), the macro emits a BijectionIso[S, S, Focus, Focus] instead of a SimpleLens. Downstream .get / .reverseGet / .modify all work without extra evidence; .andThen picks up the fused BijectionIso overloads for free.

val nameAgeIso = lens[NameAgePair](_.name, _.age)
val pair = NameAgePair("Dana", 42)
// pair: NameAgePair = NameAgePair(name = "Dana", age = 42)
val tuple = nameAgeIso.get(pair)
// tuple: NamedTuple[*:["name", *:["age", EmptyTuple]], *:[String, *:[Int, EmptyTuple]]] = (
//   "Dana",
//   42
// )
tuple.name
// res8: String = "Dana"
tuple.age
// res9: Int = 42
nameAgeIso.reverseGet(tuple)
// res10: NameAgePair = NameAgePair(name = "Dana", age = 42)

Selector-order inversion flips the NamedTuple shape:

val ageNameIso = lens[NameAgePair](_.age, _.name)
val rev = ageNameIso.get(pair)
// rev: NamedTuple[*:["age", *:["name", EmptyTuple]], *:[Int, *:[String, EmptyTuple]]] = (
//   42,
//   "Dana"
// )
rev.age
// res11: Int = 42
rev.name
// res12: String = "Dana"
ageNameIso.reverseGet(rev)
// res13: NameAgePair = NameAgePair(name = "Dana", age = 42)

Compile-time diagnostics

All macro failures surface at compile time with explicit messages prefixed lens[S]: for grep-ability:

Duplicate rejection fires at compile time:

val dup = lens[NameAgePair](_.name, _.name)
// error:
// lens[dev.constructive.eo.docs.NameAgePair]: duplicate field selector 'name' at positions 0, 1. Each field may appear at most once.
// val dup = lens[NameAgePair](_.name, _.name)
//           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Nested paths are rejected too — chain manually if you need them:

val nested = lens[Person](_.address.street)
// error:
// lens[dev.constructive.eo.docs.Person]: selector at position 0 must be a single-field accessor like `_.fieldName`. Nested paths (e.g. `_.a.b`) are not yet supported. Got: ((_$13: dev.constructive.eo.docs.Person) => _$13.address.street)
// val nested = lens[Person](_.address.street)
//              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Fine — two independent Lens derivations composed:
val ok = lens[Person](_.address).andThen(lens[Address](_.street))

prism[S, A]

A prism[S, A] derives a Prism from the parent sum type S to a specific child A <: S. Recognises:

val circleP   = prism[Shape, Shape.Circle]
val squareP   = prism[Shape, Shape.Square]
val triangleP = prism[Shape, Shape.Triangle]
circleP.to(Shape.Circle(1.0))
// res16: Either[X, Circle] = Right(Circle(1.0))
circleP.to(Shape.Square(2.0))
// res17: Either[X, Circle] = Left(Square(2.0))

circleP.modify(c => Shape.Circle(c.r * 2))(Shape.Circle(1.0))
// res18: Shape = Circle(2.0)
circleP.modify(c => Shape.Circle(c.r * 2))(Shape.Square(2.0))
// res19: Shape = Square(2.0)

Union types work the same way:

val intP = prism[Int | String, Int]
intP.to(42: Int | String)
// res20: Either[X, Int] = Right(42)
intP.to("hi": Int | String)
// res21: Either[X, Int] = Left("hi")

Composition with Lens chains

prism ∘ lens works naturally through Composer bridges:

import dev.constructive.eo.data.Affine

val circleCoordsX =
  prism[Shape2, Shape2.Circle]
    .andThen(lens[Shape2.Circle](_.c))
    .andThen(lens[Coords](_.x))
circleCoordsX.modify(_ + 10)(Shape2.Circle(Coords(3, 4), 1.0))
// res22: Shape2 = Circle(c = Coords(x = 13, y = 4), r = 1.0)
circleCoordsX.modify(_ + 10)(Shape2.Square(Coords(3, 4), 2.0))
// res23: Shape2 = Square(c = Coords(x = 3, y = 4), s = 2.0)

Macro errors

Both macros fail at compile time with explicit errors when their input doesn't fit. Examples:

See the Scaladoc for LensMacro and PrismMacro for the implementation details.