Getting started
Don't worry about an abstraction tax: cats-eo turns deep, awkward read/modify code into a readable one-liner and runs at hand-written speed — matching or beating Monocle on the single-optic hot paths (benchmarks).
Two ways in, depending on where your data lives. This page works on
in-memory values — case classes and collections. If your data
arrives encoded — circe Json, Avro records, raw JSON bytes —
start from the integrations (circe,
Avro, jsoniter):
the same optic surface applies there, with no decode/re-encode
round trip, and the habits below carry over unchanged.
Install
Add the module(s) you need to your build.sbt:
libraryDependencies ++= Seq(
"dev.constructive" %% "cats-eo" % "0.13",
"dev.constructive" %% "cats-eo-generics" % "0.13", // optional: macro-derived optics
"dev.constructive" %% "cats-eo-circe" % "0.13", // optional: JSON optics
"dev.constructive" %% "cats-eo-laws" % "0.13" % Test,
)
Requires Scala 3.8.x on JDK 17 or JDK 21.
Define
import dev.constructive.eo.CanModify
import dev.constructive.eo.generics.lens
import dev.constructive.eo.docs.{Address, Person, Zip}
A Lens focuses a single field of a product:
val nameL = lens[Person](_.name)
// nameL: SimpleLens[Person, String, NamedTuple[*:["address", EmptyTuple], *:[Address, EmptyTuple]]] = dev.constructive.eo.optics.SimpleLens@30e1a23a
val alice = Person("ms. alice liddell, esq.", Address("main st.", Zip(12345, "6789")))
// alice: Person = Person(
// name = "ms. alice liddell, esq.",
// address = Address(
// street = "main st.",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
nameL.get(alice)
// res0: String = "ms. alice liddell, esq."
A rewrite goes through the same lens. The rule is a plain
String => String — a text-utilities function that has never heard
of Person: split into words, strip symbols other than '.',
shout the short words, title-case the rest:
def emphasize(s: String): String =
s.split(" ")
.map(_.filter(c => c.isLetterOrDigit || c == '.'))
.map(w => if w.length < 4 then w.toUpperCase else w.capitalize)
.mkString(" ")
nameL.modify(emphasize)(alice)
// res1: Person = Person(
// name = "MS. Alice Liddell Esq.",
// address = Address(
// street = "main st.",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
Compose two lenses with .andThen — the result is another Lens
into the composite path:
val streetL =
lens[Person](_.address)
.andThen(lens[Address](_.street))
// streetL: SplitCombineLens[Person, Person, String, String, Tuple2[NamedTuple[*:["name", EmptyTuple], *:[String, EmptyTuple]], NamedTuple[*:["zip", EmptyTuple], *:[Zip, EmptyTuple]]]] = dev.constructive.eo.optics.SplitCombineLens@6d1fc6f
streetL.get(alice)
// res2: String = "main st."
streetL.replace("Broadway")(alice)
// res3: Person = Person(
// name = "ms. alice liddell, esq.",
// address = Address(
// street = "Broadway",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
streetL.modify(emphasize)(alice)
// res4: Person = Person(
// name = "ms. alice liddell, esq.",
// address = Address(
// street = "Main ST.",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
No .copy chains; the lens macro generates the setter for you.
Call
nameL.modify(emphasize)(alice) works, but the line couples three
decisions: the rule (emphasize), the shape (Person), and the
field (name). Move the rule behind a capability and the shape
and field become the caller's business:
def emphasized[T](using cm: CanModify[T, String]): T => T =
cm.modify(emphasize)
The signature is the contract: "give me proof a String can be
rewritten inside T, and I'll return the rewrite." Any optic that
can modify — a lens, a composed path, a prism into one branch — is
that proof — including the composed streetL, which stays a
concrete lens through .andThen:
emphasized(using nameL)(alice)
// res5: Person = Person(
// name = "MS. Alice Liddell Esq.",
// address = Address(
// street = "main st.",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
emphasized(using streetL)(alice)
// res6: Person = Person(
// name = "ms. alice liddell, esq.",
// address = Address(
// street = "Main ST.",
// zip = Zip(code = 12345, extension = "6789")
// )
// )
(An optic known only at the generic Optic[…, F] type — a
cross-carrier composition, a Traversal — is bound as a given
instead, and the capability derives from it on the spot; see
Capabilities.)
This is the library's core habit: use capabilities to write truly modular, de-coupled code by passing optics along as proof of capability contracts across modules. Deep dive: Capabilities.
Learn More
- If you want to understand what a carrier is and why one trait backs every optic family, read Concepts.
- If the capability contract above is the part that hooked you, Capabilities has the full matrix and the coherence rules.
- If you want the per-family tour with the kind of optic you'd reach for in each situation, read Optics reference.
- If you'd rather start from a task — "edit one branch", "batch-load nested IDs" — the Cookbook opens with a task index that maps straight to runnable recipes.
- If you're coming from Monocle, jump to Migrating from Monocle.
Contribute
For contributors working on the library itself:
sbt compile # full build
sbt test # core + tests + generics + circe
sbt "~compile" # watch compile
sbt docs/mdoc # compile site's code fences
sbt docs/tlSitePreview # preview the site locally
scalafmt && scalafmt --check # format + verify
sbt "clean; coverage; tests/test; coverageReport" # coverage report
Benchmarks live outside the root aggregator — see
benchmarks/README.md
for run instructions.