Concepts
cats-eo unifies every optic family behind one trait:
trait Optic[S, T, A, B, F[_, _]]:
type X
def to: S => F[X, A]
def from: F[X, B] => T
Every family — Lens, Prism, Iso, Optional, Setter, Getter, Fold,
Traversal — is a specialisation of this shape differing only in
the carrier F[_, _]. Composition crosses families by
morphing from one carrier to another rather than hand-rolling
.andThen overloads for every pair.
Existential vs. profunctor encoding
The classical profunctor presentation quantifies universally over a profunctor:
type Optic[S, T, A, B] = [P[_, _]] => Profunctor[P] ?=> P[A, B] => P[S, T]
Each optic is a polymorphic method. Every call site re-runs the profunctor argument through the universal quantifier.
The existential presentation flips the quantifier: a carrier
F[_, _] and an existential witness X are exposed rather
than quantified over. The optic is then a plain pair of
functions:
(S => F[X, A], F[X, B] => T)
Written as a value — not a method. That one shift has three consequences:
- Every optic is a plain
traitinstance. No polymorphic method invocation at the call site, no inlining-visible typeclass dispatch through a forall. - The carrier exposes capability. Whether you can
getdepends on whetherFhas anAccessor[F]— not on an abstract ProfunctorThing. One capability typeclass per operation, one instance per carrier. - Cross-family composition is a bridge problem, not a
polymorphism problem. Lens → Optional composition comes
from a
Composer[Tuple2, Affine]value, not from cleverness in the Optic trait itself.
Carriers
A carrier F[_, _] answers: "what shape does the middle of
this optic have?"
| Carrier | Shape | Family |
|---|---|---|
Tuple2 |
(X, A) — both halves always present |
Lens |
Either |
Either[X, A] — branch present or absent |
Prism |
Forgetful |
A — identity; no leftover |
Iso, Getter |
Affine |
Either[Fst[X], (Snd[X], A)] |
Optional |
SetterF |
(Fst[X], Snd[X] => A) |
Setter |
Forget[F] |
F[A] — a Foldable/Traverse container |
Fold, Traversal |
PowerSeries |
(Snd[X], Vect[Int, A]) |
Composable Traversal |
FixedTraversal[N] |
Fixed-length tuple of As |
Traversal.{two,three,four} |
What a carrier supports is exactly what its typeclass instances provide:
| Typeclass | Unlocks on Optic[…, F] |
|---|---|
Accessor[F] |
.get(s) |
ReverseAccessor[F] |
.reverseGet(b) |
ForgetfulFunctor[F] |
.modify(f), .replace(b) |
ForgetfulApplicative[F] |
.put(f) |
ForgetfulTraverse[F, Applicative] |
.modifyA[G], .all(s) |
ForgetfulFold[F] |
.foldMap[M](f) |
AssociativeFunctor[F, X, Y] |
.andThen(other) under the same F |
Composer[F, G] |
.morph[G] |
One optic trait, one instance per operation per carrier. Adding
a new carrier means supplying the typeclass instances the
operations it wants to support need — not rewriting Optic or
the existing families.
Composition
Same-carrier: Optic.andThen
When two optics share F, Optic.andThen composes them under
that carrier:
import eo.optics.{Lens, Optic}
import eo.optics.Optic.*
case class Address(street: String)
case class Person(address: Address)
val personAddress =
Lens[Person, Address](_.address, (p, a) => p.copy(address = a))
val addressStreet =
Lens[Address, String](_.street, (a, s) => a.copy(street = s))
val streetL = personAddress.andThen(addressStreet)
Both pieces live in Tuple2; .andThen requires
AssociativeFunctor[Tuple2, X, Y], which is defined globally
for any X, Y.
Cross-family: .morph[G]
When the downstream optic uses a different carrier, morph the
upstream optic first via Composer[F, G]:
import eo.data.Affine
import eo.optics.Optional
case class Maybe(flag: Option[String])
case class Wrapped(maybe: Maybe)
val mainOnly = Optional[Maybe, Maybe, String, String, Affine](
getOrModify = m => m.flag.filter(_.startsWith("M")).toRight(m),
reverseGet = { case (m, s) => m.copy(flag = Some(s)) },
)
val wrappedMaybe =
Lens[Wrapped, Maybe](_.maybe, (w, m) => w.copy(maybe = m))
// `.morph[Affine]` lifts the Lens into the Optional's carrier so
// `.andThen(mainOnly)` type-checks under
// `AssociativeFunctor[Affine, X, Y]`.
val mainStreet = wrappedMaybe.morph[Affine].andThen(mainOnly)
Composer[Tuple2, Affine] is one of the stdlib instances;
eo.data.Affine
ships it. Other bridges: Tuple2 → SetterF, Tuple2 →
PowerSeries, Either → Affine, Either → PowerSeries,
Affine → PowerSeries, Forgetful → Tuple2, Forgetful →
Either.
The transitive Composer.chain given lets you hop across two
bridges without naming the intermediate.
Why the existential machinery is worth it
Unifying every optic behind a single trait collapses the usual
per-family surface ten-fold: no Lens.modify,
Prism.getOption, Iso.reverseGet each written separately.
Extensions on Optic are written once against a capability
typeclass (ForgetfulFunctor[F] for .modify) and any family
whose carrier supplies the instance gets the method for free.
The runtime cost is also neutral. Each carrier's
concrete-class specialisation
(GetReplaceLens,
MendTearPrism,
BijectionIso)
stores get/replace/reverseGet as plain fields so the hot
path bypasses typeclass dispatch entirely. See the
benchmark notes
for side-by-side numbers against Monocle.