cats-eo

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