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 indev.constructive.eo.docs.*for that reason — the samelens/prismcalls 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:
- Empty varargs —
lens[Customer]()→ "requires at least one field selector". - Non-case-class source —
lens[SomeInterface](...)→ Hearth'sCaseClass.parsediagnostic. - Non-field selector —
lens[Customer](_.name.toUpperCase)→ "selector at position 0 must be a single-field accessor like_.fieldName. Nested paths (e.g._.a.b) are not yet supported." - Unknown field —
lens[Widget](_.bogus)→ "'bogus' is not a field of Widget. Known fields: name, size". - Duplicate selectors —
lens[OrderItem](_.sku, _.sku)→ "duplicate field selector 'sku' at positions 0, 1. Each field may appear at most once."
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:
- Scala 3 enums,
- Sealed traits with direct child types,
- Scala 3 union types.
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:
lens[Person](_.address.street)— nested paths not yet supported in a single macro call; chain instead:lens[Person](_.address).andThen(lens[Address](_.street)).prism[Shape, OtherEnum.Foo]—Amust be a direct child ofS; otherwise the macro aborts with "not a direct child of".lens[NonCaseClass](_.field)— only works on case classes.
See the Scaladoc for
LensMacro
and
PrismMacro
for the implementation details.