cats-eo

Optics reference

One section per family — the shape, carrier, primary use case, and a minimal runnable example. For the per-method reference see the Scaladoc.

Family taxonomy

Every family is a specialisation of the same Optic[S, T, A, B, F] trait, differing only in the carrier F[_, _]. The diagram is a composition lattice: an edge A → B means every A is a B, so composing two optics lands on their join — the lowest node both reach by following edges down. Iso.andThen(Lens) = Lens; Lens.andThen(Prism) lands on the Affine carrier; a read-only chain lands in the single-direction group.

flowchart TD
  subgraph bidir["Bi-directional — read and write"]
    Iso --> Lens
    Iso --> Prism
    Lens --> Affine
    Prism --> Affine
    Affine --> MultiFocus["MultiFocus[F]"]
    Iso --> MultiFocus
  end

  subgraph single["Single direction"]
    Getter["Getter — read-only"]
    Setter["Setter — write-only"]
    Review["Review — build-only"]
  end

  Iso --> Getter
  Lens --> Getter
  MultiFocus --> Setter

  click Iso "#iso"
  click Lens "#lens"
  click Prism "#prism"
  click Affine "#affine"
  click MultiFocus "#multifocus"
  click Getter "#getter"
  click Setter "#setter"
  click Review "#review"

How to read it:

Affine is the carrier shared by Optional (read and write) and AffineFold (read-only). MultiFocus[F] is the multi-focus carrier; its sub-shapes (PowerSeries, Grate, Kaleidoscope, AlgLens[F]) are selected by F. The full cell-by-cell composition matrix lives in docs/research/2026-04-23-composition-gap-analysis.md — the lattice above is its geometric view.

import dev.constructive.eo.optics.{Lens, Optic}
import dev.constructive.eo.optics.Optic.*
// `Fold.apply` / `.select` now return the concrete `ForgetFold`, whose eager `foldMap`
// member needs no `import Forget.given` — the carrier's `ForgetfulFold` is no longer summoned.
// (Accessor[Direct] etc. resolve via `object Direct`'s companion scope — `Direct` is an
//  opaque type, so no `import Direct.given` is needed for `.get` on Iso / Getter.)

Every page here shows optics constructed by hand. For the macro-derived lens[S](_.field) / prism[S, A] flavour, see Generics.

Iso

An Iso[S, A] is a bijection — every S round-trips to exactly one A and back. Carrier: Direct (the identity carrier).

import dev.constructive.eo.optics.Iso

case class PersonPair(age: Int, name: String)
val pairIso = Iso[(Int, String), (Int, String), PersonPair, PersonPair](
  t => PersonPair(t._1, t._2),
  p => (p.age, p.name),
)
pairIso.get((30, "Alice"))
// res0: PersonPair = PersonPair(age = 30, name = "Alice")
pairIso.reverseGet(PersonPair(30, "Alice"))
// res1: Tuple2[Int, String] = (30, "Alice")

Lens

A Lens[S, A] focuses a single, always-present field of a product type. Carrier: Tuple2.

case class Person(name: String, age: Int)
val ageL = Lens[Person, Int](_.age, (p, a) => p.copy(age = a))
val alice = Person("Alice", 30)
// alice: Person = Person(name = "Alice", age = 30)
ageL.get(alice)
// res2: Int = 30
ageL.replace(31)(alice)
// res3: Person = Person(name = "Alice", age = 31)
ageL.modify(_ + 1)(alice)
// res4: Person = Person(name = "Alice", age = 31)

Composes via .andThen with other Lenses and — transparently, with no extra syntax — with Optional / Setter / Traversal optics too. The cross-carrier variant of .andThen summons a Composer[F, G] or Composer[G, F] to bring both sides under a common carrier.

Prism

A Prism[S, A] focuses one branch of a sum type — Some over None, or a specific case of an enum. Carrier: Either.

import dev.constructive.eo.optics.Prism

enum Shape:
  case Circle(r: Double)
  case Square(s: Double)

val circleP = Prism[Shape, Shape.Circle](
  {
    case c: Shape.Circle => Right(c)
    case other           => Left(other)
  },
  identity,
)
circleP.to(Shape.Circle(1.0))
// res5: Either[Shape, Circle] = Right(Circle(1.0))
circleP.to(Shape.Square(2.0))
// res6: Either[Shape, Circle] = Left(Square(2.0))

// modify acts only on the Circle branch; Squares pass through
// unchanged.
circleP.modify(c => Shape.Circle(c.r * 2))(Shape.Circle(1.0))
// res7: Shape = Circle(2.0)
circleP.modify(c => Shape.Circle(c.r * 2))(Shape.Square(2.0))
// res8: Shape = Square(2.0)

For auto-derivation on enums / sealed traits / union types see prism[S, A] in Generics.

Affine

The Affine carrier focuses a value that may or may not be present — a 0-or-1 focus. Two families ride it: Optional (read and write) and AffineFold (read-only).

Optional

An Optional[S, A] focuses a conditionally-present field — an Option[A] field, a predicate-gated access, a refinement-style narrowing.

import dev.constructive.eo.data.Affine
import dev.constructive.eo.optics.Optional

case class Contact(flag: Option[String])

val presentFlag = Optional[Contact, Contact, String, String, Affine](
  getOrModify = c => c.flag.toRight(c),
  reverseGet  = { case (c, s) => c.copy(flag = Some(s)) },
)
presentFlag.modify(_.toUpperCase)(Contact(Some("hello")))
// res9: Contact = Contact(Some("HELLO"))
presentFlag.modify(_.toUpperCase)(Contact(None))
// res10: Contact = Contact(None)

Composition with a Lens is automatic: lens.andThen(optional) summons Composer[Tuple2, Affine] under the hood and morphs the Lens into the Affine carrier. No explicit .morph required on your end.

Read-only / write-only collapse. Composing any optic with a read-only Getter projects it to its read-only counterpart: the Getter's Unit back-focus can't thread through a writable B, so the write side is forgotten (T = B = Unit). A ReadOnly[F] carrier projection picks the result — a total reader (Lens / Iso) yields a Getter, a partial one (Optional / Prism) an AffineFold:

lens.andThen(getter)      // Getter
optional.andThen(getter)  // AffineFold  (partial read)
prism.andThen(getter)     // AffineFold

Dually, composing with a write-only Setter collapses the read side and yields a Setter (lens.andThen(setter), optional.andThen(setter), …) — it modifies the focus through the inner setter. One rule per side, across the whole algebra, rather than a per-family special case.

AffineFold (read-only)

The read-only projection of an Affine — a 0-or-1 focus with no write-back path. Optional.readOnly / Optional.selectReadOnly build one from the "read-only Optional" mental model. Full treatment, with the read-only-direction story, lives in Single direction → AffineFold.

MultiFocus

MultiFocus[F][X, A] = (X, F[A]) — a structural leftover paired with an F-shaped bundle of foci. It is the carrier for every optic that focuses more than one value at once; the surface lights up by the typeclasses F admits (.modify for Functor, .foldMap for Foldable, .modifyA for Traverse, .at(i) for Representable, .collectMap / .collectList for aggregation, and same-carrier .andThen). The sub-shapes below are just different Fs.

See the MultiFocus reference for the full typeclass-gated capability matrix and composability profile; the Cookbook ships runnable recipes for the Grate, Kaleidoscope, and PowerSeries shapes.

PowerSeries

MultiFocus[PSVec] — the Traversal.each / Traversal.pEach carrier. Map, fold, or traverse every element of a collection, and keep composing past the traversal with .andThen. Supports .modify / .replace (Functor), .foldMap (Foldable), .modifyA / .all (Traverse), and downstream .andThen via mfAssocPSVec. Overhead over a naive copy/map runs ~2-3× for dense chains and ~5× for the Prism miss-branch shape, amortising down as the collection grows (the benchmarks sweep sizes 4 / 32 / 256 / 1024).

Plated — the recursive self-traversal behind transform / universe / everywhere — rides this same MultiFocus[PSVec] carrier via Traversal.selfChildren; it's a typeclass over the carrier, not a new family node. See Generics → plate[S], the Cookbook, and the Setter section for everywhere.

import dev.constructive.eo.optics.Traversal
import dev.constructive.eo.data.MultiFocus.given  // Functor / Foldable / Traverse for MultiFocus[PSVec]

val listEach = Traversal.pEach[List, Int, Int]
listEach.modify(_ + 1)(List(1, 2, 3))
// res11: List[Int] = List(2, 3, 4)
listEach.foldMap(identity[Int])(List(1, 2, 3))   // sum
// res12: Int = 6

each shines when the chain continues past the traversal — e.g. "for every phone, toggle isMobile":

case class Phone(isMobile: Boolean, number: String)
case class Owner(phones: List[Phone])

val ownerAllPhonesMobile =
  Lens[Owner, List[Phone]](_.phones, (o, ps) => o.copy(phones = ps))
    .andThen(Traversal.each[List, Phone])
    .andThen(Lens[Phone, Boolean](_.isMobile, (p, m) => p.copy(isMobile = m)))
ownerAllPhonesMobile.modify(!_)(Owner(List(
  Phone(isMobile = false, "555-0001"),
  Phone(isMobile = true,  "555-0002"),
)))
// res13: Owner = Owner(
//   List(
//     Phone(isMobile = true, number = "555-0001"),
//     Phone(isMobile = false, number = "555-0002")
//   )
// )

Grate

MultiFocus[Function1[X0, *]] — a uniform rewrite across a fixed shape: homogeneous tuples and Naperian / representable containers, where every position is rebuilt the same way. The factories are MultiFocus.tuple[T <: Tuple, A] (homogeneous-tuple uniform rewrite), MultiFocus.representable[F: Representable, A] (arbitrary Naperian rebuild), and MultiFocus.representableAt (representative-index variant). See MultiFocus reference and Cookbook → Recipe A for a worked example.

Kaleidoscope

MultiFocus[F] for an F with Apply — the aggregating read: collapse every focus to a single value with .collectMap (Functor-broadcast) or .collectList (List cartesian). Reach for it when you want to read the foci out as one summary rather than rewrite them in place. See MultiFocus reference and Cookbook → Recipe B.

AlgLens[F]

MultiFocus[F] for F: Functor / Foldable / Traverse — an algebraic ("classifier") lens whose focus is computed over the structure: the read side folds/classifies, the write side broadcasts back. The MultiFocus.fromLensF / fromPrismF / fromOptionalF factories lift a single-focus optic over an F[A] focus into this shape. See MultiFocus reference and Cookbook → Recipe C.

Single direction

Optics that travel one way only — they keep a read side, a write side, or a build side, but not the round trip.

Getter

A Getter[S, A] is a pure projection — read-only. Carrier: Direct with T = Unit.

import dev.constructive.eo.optics.Getter

val nameLen = Getter[Person, Int](_.name.length)
nameLen.get(Person("Alice", 30))
// res14: Int = 5

Getter → Getter composes via the ordinary .andThen (the fused DirectGetter.andThen): g1.andThen(g2).get(s) reads g2.get(g1.get(s)).

val initial = Getter[Person, String](_.name).andThen(Getter[String, Char](_.head))
// initial: Getter[Person, Char] = dev.constructive.eo.optics.Getter@6f3d4dee
initial.get(Person("Alice", 30))
// res15: Char = 'A'

Setter

A Setter[S, A] can modify but not read — a write-only focus for cases where the focus value isn't observable to the caller. Carrier: SetterF.

import dev.constructive.eo.optics.Setter

case class SetterConfig(values: Map[String, Int])
val bumpAll = Setter[SetterConfig, SetterConfig, Int, Int] { f => cfg =>
  cfg.copy(values = cfg.values.view.mapValues(f).toMap)
}
bumpAll.modify(_ + 1)(SetterConfig(Map("a" -> 1, "b" -> 2)))
// res16: SetterConfig = SetterConfig(Map("a" -> 2, "b" -> 3))

Both lens.andThen(setter) (a Lens to a focus, then a Setter that writes into it) and setter.andThen(setter) work — SetterF ships an AssociativeFunctor[SetterF, Xo, Xi] instance, so the standard Optic.andThen resolution picks it up transparently.

import dev.constructive.eo.compose.Composer
import dev.constructive.eo.data.SetterF
import dev.constructive.eo.data.SetterF.given

final case class Box(value: Int)
final case class Holder(box: Box, tag: String)

val outer = summon[Composer[Tuple2, SetterF]].to(
  Lens[Holder, Box](_.box, (s, b) => s.copy(box = b))
)
val inner = summon[Composer[Tuple2, SetterF]].to(
  Lens[Box, Int](_.value, (s, v) => s.copy(value = v))
)
val composed = outer.andThen(inner)
composed.modify(_ + 1)(Holder(Box(10), "tag"))
// res17: Holder = Holder(box = Box(11), tag = "tag")

Setter is a write-side terminal: there is no Composer[SetterF, _] outbound, so to escape a SetterF chain into a Forget / MultiFocus / Lens you have to restructure with the Setter on the inside.

everywhere — a Setter that reaches every depth

Plated.everywhere[S] is a Setter over a recursive type whose .modify is the bottom-up recursive transform (see Generics → plate[S]). Because it's an ordinary Setter, the same .andThen you'd use to reach one focus now applies that focus at every node of the tree — the "specify once, run everywhere" payoff. Give the type a Plated (by hand here; plate[S] from eo-generics derives it):

import dev.constructive.eo.optics.Plated

enum Tree:
  case Leaf(n: Int)
  case Branch(l: Tree, r: Tree)

given Plated[Tree] = Plated.fromChildren(
  {
    case Tree.Branch(l, r) => List(l, r)
    case Tree.Leaf(_)      => Nil
  },
  {
    case (Tree.Branch(_, _), l :: r :: Nil) => Tree.Branch(l, r)
    case (leaf, _)                          => leaf
  },
)

// A Setter that writes the Int in a Leaf; everywhere lifts it to all depths.
val leafN = Setter[Tree, Tree, Int, Int] { f =>
  {
    case Tree.Leaf(n) => Tree.Leaf(f(n))
    case other        => other
  }
}

val everyLeaf = Plated.everywhere[Tree].andThen(leafN)
everyLeaf.modify(_ + 1)(Tree.Branch(Tree.Leaf(1), Tree.Branch(Tree.Leaf(2), Tree.Leaf(3))))
// res18: Tree = Branch(l = Leaf(2), r = Branch(l = Leaf(3), r = Leaf(4)))

everywhere composes outward with any inner optic that bridges into SetterF (Lens / Prism / Optional / Setter), and the .modify runs bottom-up, stack-safe to any depth. For the read side (every sub-term as a list) use Plated.universe; for the full worked Prism-composition recipe see the Cookbook, and for the macro that derives the Plated see Generics → plate[S].

Review

A Review[S, A] is the build-only optic — it wraps an A => S construction function. It is the exact mirror of Getter: where Getter is Optic[S, Unit, A, Unit, Direct] (a real read to, vestigial from), Review is Optic[Unit, S, Unit, A, Direct] — a vestigial to and a real from that builds S from the focus A. So it is a full Optic and composes through the fused andThen, just like Getter.

import dev.constructive.eo.optics.Review

val someIntR = Review[Option[Int], Int](Some(_))
someIntR.reverseGet(42)
// res19: Option[Int] = Some(42)

Compose two Reviews with andThen (build String → Int → Option[Int]):

val lengthR = Review[Int, String](_.length)
val someLen = someIntR.andThen(lengthR)
someLen.reverseGet("hello")
// res20: Option[Int] = Some(5)

There are no fromIso / fromPrism factories: an Iso or Prism already carries its build direction, so wrap it directly — Review(iso.reverseGet) or Review(prism.mend). eo has no Prism.fromIso (and the like) for the same reason — a cross-optic conversion that merely re-exposes a sub-direction the source already has would be redundant. (A general, non-bijective Lens can't reconstruct its source from the focus alone, so there is deliberately no Lens→Review path; build a Review with your own A => S.)

AffineFold

An AffineFold[S, A] is the read-only 0-or-1 focus shape: a partial projection with no write-back path. Type alias for Optic[S, Unit, A, A, Affine] — the T = Unit slot statically rules out .modify / .replace, so the only operations are .getOption, .foldMap, and .modifyA (effectful read).

Use this when the source has no natural write-back (headOption on a List, predicate-gated filters), or as an API-boundary declaration that callers cannot write through the returned optic.

import dev.constructive.eo.optics.AffineFold

case class Adult(age: Int)
val adultAge: AffineFold[Adult, Int] =
  AffineFold(p => Option.when(p.age >= 18)(p.age))
adultAge.getOption(Adult(20))
// res21: Option[Int] = Some(20)
adultAge.getOption(Adult(15))
// res22: Option[Int] = None

AffineFold.select(p) is the filtering variant:

val evenAF = AffineFold.select[Int](_ % 2 == 0)
evenAF.getOption(4)
// res23: Option[Int] = Some(4)
evenAF.getOption(3)
// res24: Option[Int] = None

Narrow an existing Optional or Prism to its read-only projection with AffineFold(optic.getOption) — .getOption is defined on both the Affine and Either carriers, so this holds the matcher while discarding the write / build path. (There is no bespoke fromOptional / fromPrism factory: the conversion is a one-liner, and eo provides no Getter.fromLens / Fold.fromTraversal for the same reason.)

Composition note. Direct lens.andThen(af) on an AffineFold does not type-check: the outer B slot doesn't align with the inner T = Unit. Build a full composed Optional through the Lens chain and narrow the result with AffineFold(optional.getOption).

Fold

A Fold[F, A] summarises every element of a Foldable[F] via Monoid[M] — read-only, multi-element. Carrier: Forget[F].

import cats.instances.list.given
import dev.constructive.eo.optics.Fold

val listFold = Fold[List, Int]
listFold.foldMap(identity[Int])(List(1, 2, 3))
// res25: Int = 6
listFold.foldMap((i: Int) => i * i)(List(1, 2, 3))
// res26: Int = 14

Fold.select(p) narrows to elements matching a predicate:

val positive = Fold.select[Int](_ > 0)
positive.foldMap(identity[Int])(3)
// res27: Int = 3
positive.foldMap(identity[Int])(-3)
// res28: Int = 0

Composition limits

A few categories of pair are either intentionally not bridged or only bridged through a user-opt-in side-channel. Each entry states the structural shape, the rationale, and the idiomatic workaround:

Lens / Prism / Optional × Fold[F] when the outer focuses on a scalar A — the outer never produces an F-shape, so there's nothing for the Fold to traverse. Use fold.foldMap(f)(lens.get(s)) directly. If your outer does focus on an F[A] (e.g. Lens[Row, List[Int]]), use one of the MultiFocus.fromLensF / fromPrismF / fromOptionalF factories to lift into MultiFocus[F] and chain there.

Traversal.each × Fold[F] / MultiFocus[F] — MultiFocus[PSVec] (the Traversal.each carrier) cannot widen into another MultiFocus[G]'s per-candidate cardinality model without a synthetic count. The idiomatic workaround pushes the inner under the traversal: traversal.modify(a => inner.replace(b)(a))(s) for a MultiFocus inner; traversal.foldMap(f)(s) (the read-only escape on any MultiFocus[F]-carrier optic) when you only need the fold side.

Cross-F Fold[F].andThen(Fold[G]) — Composer[Forget[F], Forget[G]] doesn't ship (Composer's signature has no slot for a per-call natural transformation, and the carrier-generic Optic.andThen requires the same F). Instead, Forget.scala ships a Forget-specific .andThen extension that takes a user-supplied cats.~>[F, G] plus FlatMap[G] and produces a Forget[G]-carrier optic:

import cats.~>
val outer: Optic[Source, Unit, A, A, Forget[List]]   = ...
val inner: Optic[A, Unit, B, B, Forget[Option]]      = ...
given listHead: List ~> Option = new (List ~> Option):
  def apply[T](xs: List[T]): Option[T] = xs.headOption
val composed: Optic[Source, Unit, B, B, Forget[Option]] =
  outer.andThen(inner)

The user picks the meaning by choosing the nat (e.g. List ~> Option via headOption, Option ~> List via toList, List ~> LazyList for streaming). Result carrier is Forget[G] — downstream composition continues in G's typeclass landscape. Restricted to T = Unit (the Fold case) since cross-F composition has no natural way to thread from for general T.

SetterF outbound — Setter is a write-side terminal: there is no outbound Composer[SetterF, _], so a chain that reaches Setter cannot widen back into a Forget / MultiFocus / Lens. Same-carrier setter.andThen(setter) does work — SetterF.assocSetterF ships AssociativeFunctor[SetterF, Xo, Xi] with Z = (Fst[Xo], Snd[Xi]), so the standard Optic.andThen resolves transparently.

Fixed-arity traversal (Traversal.two / .three / .four) — these factories produce MultiFocus[Function1[Int, *]]-carrier optics, so they inherit the Grate sub-shape's composability: Iso ↪ MF[Function1[Int, *]], MF[Function1[Int, *]] ↪ SetterF, and same-carrier .andThen via mfAssocFunction1. Lens / Prism / Optional do NOT bridge in (Function1 lacks Foldable / Alternative).

The full taxonomy with cell-by-cell rationale lives in docs/research/2026-04-23-composition-gap-analysis.md.