cats-eo

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

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.