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. Three
macros — lens, prism, and plate — 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.13"
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. Deriving for a type nested inside a class fails at compile time with "missing outer accessor in class …" — the emittednew S(...)call carries no outer pointer. The fix is to move the ADT to the top level. 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.
Coming from Monocle, this is EO's GenLens[S](_.field) / Focus[S](_.field),
with three differences: nested paths (_.a.b) are not supported in a single
call (chain .andThen instead), a selector set covering every field promotes
the result to a BijectionIso (the GenIso role — see below), and enum cases
work. Likewise, prism[S, A] below is GenPrism extended to union types.
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.
One surprise at arity 1: on a 1-field case class the single selector already covers every field, solens[Wrapper](_.value)returns aBijectionIsowhose focus is a 1-field NamedTuple —.getyields(value = 42), not the bare42. See the full-cover Iso section below.
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 → Effect threading) 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.
What generics deliberately does not derive
Knowing the module's edges saves a search through the API for things that aren't there:
- No Focus-style path DSL. A nested selector path like
lens[Person](_.address.street)is a compile error, not a deep lens. Chain two derivations with.andTheninstead — composition is the module's whole design, and the composed optic fuses just as well. - No Traversal derivation. There is no
each-style macro; collection traversal lives in core (Traversal.eachand friends) and composes with derived lenses and prisms through the same.andThen. - No per-ADT optic bundles. Each call derives exactly one optic. Nothing
generates "all the lenses of
Person" as a companion-object bundle — you ask for the fields you use. - No runtime reflection. Everything happens at compile time; the emitted code is the same construction and pattern match you'd write by hand.
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 — chain two derivations instead:lens[S](_.a).andThen(lens[A](_.b))." - 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 — chain two derivations instead: `lens[S](_.a).andThen(lens[A](_.b))`. 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 — but note what the
composite is: a lens drilled through a prism can miss (the shape might be a
Square), so the result auto-upgrades to the Affine carrier. That's why the
import dev.constructive.eo.data.Affine below is load-bearing — the composed
optic's type names Affine, so it must be in scope wherever that type is
written down:
import dev.constructive.eo.data.Affine
val circleCoordsX =
prism[Shape2, Shape2.Circle]
.andThen(lens[Shape2.Circle](_.c))
.andThen(lens[Coords](_.x))
An Affine optic has no total .get — read through .getOption, and writes
(.modify / .replace) simply no-op on the miss branch:
circleCoordsX.getOption(Shape2.Circle(Coords(3, 4), 1.0))
// res22: Option[Int] = Some(3)
circleCoordsX.getOption(Shape2.Square(Coords(3, 4), 2.0))
// res23: Option[Int] = None
circleCoordsX.modify(_ + 10)(Shape2.Circle(Coords(3, 4), 1.0))
// res24: Shape2 = Circle(c = Coords(x = 13, y = 4), r = 1.0)
circleCoordsX.modify(_ + 10)(Shape2.Square(Coords(3, 4), 2.0))
// res25: 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;
the cookbook runs the same derivation against a package-level
sample ADT 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.