cats-eo

Generics

Writing a Lens or Prism by hand is mechanical, repetitive work — a getter, a setter that rebuilds the whole case class around one changed field, a pattern match per sum-type branch. cats-eo-generics writes all of it for you. Two macros, lens and prism, inspect your case classes and enums at compile time and emit the optic directly: no runtime reflection, no derivation typeclass to summon, no per-field wiring to keep in sync as the type evolves. What they generate is the same new S(...) construction and pattern match you'd have typed yourself, so a derived optic runs as fast as a hand-written one (it matches Monocle's specialised classes — see the benchmarks) while collapsing to a single readable line.

And because each macro returns an ordinary Optic value, the payoff goes beyond saved keystrokes: derived optics compose — with each other, with your own domain functions, and with the serialization layer — so a data transformation, or an entire request handler, becomes one clean expression instead of a nest of copy calls. Composing derived optics into pipelines builds that up.

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)

Point at a field and you get a Lens to it. It's a two-step call — lens[Customer] pins the source type, the second call picks the field — which keeps the field selector fully type-checked against Customer:

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)

Composing derived optics into pipelines

A derived optic is just a value of type Optic[…], and that's what makes the generics module more than a typing shortcut. Three things compose with it for free:

Other optics. lens and prism results .andThen each other — and every hand-written optic — across carriers, so you build a deep accessor out of small derived pieces rather than spelling the traversal by hand: prism[Event, Event.Purchase].andThen(lens[Purchase](_.amount)). The cross-carrier bridging is automatic; see Concepts → Cross-family.

Your own functions. The focus is an ordinary value, so .modify, .foldMap, and friends take your domain functions directly — a pricing rule, a normaliser, a validation. You write the business logic; the optic does the "reach in, rebuild around it" plumbing. Chaining a few reads as a clean left-to-right pipeline instead of nested copy calls.

The wire. Pair a derived optic with a serialization codec and the same .get / .replace / .modify vocabulary spans the gap between your domain types and their on-the-wire form: decode once, transform through optics, re-encode — or, with eo-circe / eo-avro, edit the encoded form in place and never fully decode at all.

Put together, that turns a request handler into a short pipeline. The persist-and-stamp recipe (Cookbook → Theme H) is the canonical shape — decode a PUT body into a domain object, store it effectfully, stamp the database-assigned id back on with a derived id-lens, and re-encode the result: the whole handler reads as decode → store → stamp → encode.

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)

plate[S] — recursive self-traversal (Plated)

plate[S] derives a Plated[S] — the immediate same-typed-children traversal behind transform / rewrite / children / universe over a recursive ADT. It focuses every field whose type is exactly S across all cases (the exact self-type rule); fields of other types stay as leftover skeleton.

The derived instance also backs Plated.everywhere[S] — a composable Modify that lifts any downstream optic to every depth, so everywhere[S].andThen(prism).modify(f) rewrites that focus across the whole tree. See Optics → Modify and the Cookbook recipe.

import dev.constructive.eo.generics.plate
import dev.constructive.eo.optics.Plated

enum Expr:
  case Var(name: String)
  case App(f: Expr, x: Expr)
  case Lam(bind: String, body: Expr)

// App.f, App.x and Lam.body are the recursion points; Lam.bind is a leaf.
given Plated[Expr] = plate[Expr]

// Uppercase every variable occurrence, anywhere in the tree (stack-safe).
Plated.transform { case Expr.Var(n) => Expr.Var(n.toUpperCase); case e => e }(
  Expr.App(Expr.Var("f"), Expr.Lam("y", Expr.Var("y")))
)

Works on enums, sealed hierarchies, and recursive case classes. See the cookbook Plated recipe for a worked, runnable example. (The block above is illustrative — the new the macro emits for an enum case can't run inside this page's doc sandbox, so the cookbook spells the instance out by hand instead.)

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.