cats-eo
An existential optics library for Scala 3, built on cats.
One Optic[S, T, A, B, F] trait, parameterised over a carrier
F[_, _], unifies every optic family. Composition crosses
families through Composer[F, G] bridges rather than N² hand-
written .andThen overloads.
Stacking Benefits
What an optic buys you depends on the scale you're working at — and it compounds.
At the value level: a pinpointed operation, cleanly specified.
Name the part you care about and act on it in one line — no .copy
chains, no cursor plumbing, no depth tax.
At the format level: the same one line, regardless of encoding.
Lens / Prism / Traversal read identically against in-memory trees,
circe Json (eo-circe), Avro on the wire
(eo-avro), and raw JSON bytes
(eo-jsoniter) — where the byte-level
edit is flat in document size and decode-modify-encode is
on-demand.
At the architecture level: the optic becomes a contract. A module
demands CanModify[T, Instant] — proof that doesn't anchor T —
and the caller supplies the optic relevant to their particular
context. Truly modular, de-coupled code: optics passed along as
proof of capability contracts across modules
(Capabilities).
The whole ladder runs at hand-written speed — fused hot paths match or beat what's currently done on large systems, and capability calls dispatch into the same fused methods (Benchmarks).
Current version: 0.6.
Ergonomics
import dev.constructive.eo.CanModify
import dev.constructive.eo.generics.lens
import dev.constructive.eo.docs.{Address, Person, Zip}
val personName = lens[Person](_.name)
val personAddress = lens[Person](_.address)
val addressStreet = lens[Address](_.street)
val street = personAddress.andThen(addressStreet)
val alice = Person("Alice", Address("Main St", Zip(12345, "6789")))
// alice: Person = Person(
// name = "Alice",
// address = Address(
// street = "Main St",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
street.get(alice)
// res0: String = "Main St"
street.replace("Broadway")(alice)
// res1: Person = Person(
// name = "Alice",
// address = Address(
// street = "Broadway",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
No .copy chains, no setter lambdas, no GenLens boilerplate. The
lens macro works on plain case classes, Scala 3 enums, and union
types alike.
And the part that scales beyond one call site: a function can demand
the capability instead of the type. shout below knows nothing
about Person or Address — it asks for proof that a String can
be rewritten inside T, and any optic with that power is the proof:
def shout[T](cm: CanModify[T, String]): T => T =
cm.modify(_.toUpperCase)
shout(personName)(alice)
// res2: Person = Person(
// name = "ALICE",
// address = Address(
// street = "Main St",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
shout(street)(alice)
// res3: Person = Person(
// name = "Alice",
// address = Address(
// street = "MAIN ST",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
One generic function, two different paths — a plain lens or a composed one, handed over as the proof. The optic passed at the call site is the entire coupling surface; that contract style is the library's core habit: Capabilities.
Further Reading
- Getting started — install + the 60-second tour expanded.
- Capabilities —
CanGet/CanModify/CanFold& co: consume optics asusingevidence and keep modules decoupled; the availability matrix and coherence rules. - Concepts — what an Optic is, what a carrier is,
and how
Composerbridges family boundaries. - Optics reference — one section per family, with runnable examples and the compiler-pinned 11-family composition matrix.
- MultiFocus — the unified successor of five v1
carriers (
AlgLens[F],Kaleidoscope,Grate,PowerSeries,FixedTraversal[N]); typeclass-gated capability matrix and composability profile. - Generics — the
lens[S](_.field)andprism[S, A]macros, backed by Hearth. - Circe integration —
JsonPrism/JsonTraversal, cursor-backed navigation into circeJsonwith no full decode. - Avro integration —
AvroPrism/AvroTraversal, cursor-backed navigation into Apache AvroIndexedRecordwith no full decode; binary + JSON wire-format input dual. - Jsoniter integration —
JsoniterPrism/JsoniterTraversal, byte-cursor navigation directly intoArray[Byte]JSON via jsoniter-scala codecs. Read at ~50 ns/op (16× eo-circe), write via splice at ~100 ns/op (14× eo-circe). - Extensibility — how to ship a custom optic tuned for your domain's hot path without losing the rest of the cats-eo universe.
- Cookbook — common patterns: option fields,
composed Lens/Optional, multi-focus modify, JSON path edits, and
Plated+everywhere— one optic applied at every depth of a tree, AST, or JSON document. - Migrating from Monocle — a translation table + where EO diverges.