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.

What optics buy you

An optic is a tool for cleanly specifying a complex, pinpointed operation against a value — at a much higher level of interpretation than the operation itself. You name which part of a value you care about; the carrier names how that focus is reached and rebuilt; your action against the focus stays a one-liner regardless of how deep the structure goes or how heterogeneously the layers are encoded.

The Lens / Prism / Traversal vocabulary you'd normally use for in-memory case-class trees is the same vocabulary that lights up when one side of the structure is a JSON byte stream (eo-jsoniter) or an Apache Avro record on the wire (eo-avro) or a circe Json AST (eo-circe). You get to specify one side of the mirror — the focus, the operation, the path — and let the carrier implement the other. On one side: the bytestream, the wire, the buffered representation. On the other: your domain classes. Same .modify / .replace / .andThen reads against both.

Current version: 0.1.

60-second example

import dev.constructive.eo.optics.Optic.*
import dev.constructive.eo.generics.lens
import dev.constructive.eo.docs.{Address, Person, Zip}

val street =
  lens[Person](_.address)
    .andThen(lens[Address](_.street))
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")
//   )
// )
street.modify(_.toUpperCase)(alice)
// res2: Person = Person(
//   name = "Alice",
//   address = Address(
//     street = "MAIN ST",
//     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.

Keep reading