Optics reference
One section per family, each with 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 below is
a Hasse-style composition lattice: an edge A → B means every
A is a B (the carrier admits the conversion natively, often
with a fused .andThen overload). When you compose two optics, the
result family is their join — the lowest node both originals can
reach by following edges down. So Iso.andThen(Lens) lands on
Lens; Lens.andThen(Prism) lands on Optional;
Optional.andThen(Traversal) lands on Traversal; any read-only
chain lands on Fold. Click a node to jump to its section.
flowchart TD
%% Read-write spine — Iso ⊏ {Lens, Prism} ⊏ Optional ⊏ Traversal ⊏ Setter.
%% Each downward edge is a native specialisation; composing two adjacent
%% families lands at the lower node by following both arrows down.
Iso --> Lens
Iso --> Prism
Lens --> Optional
Prism --> Optional
Optional --> Traversal
Traversal --> Setter
%% Read-only branch — drops the write side (T = Unit). Every read-write
%% family has a read-only counterpart that fans into Fold.
Iso --> Getter
Lens --> Getter
Optional --> AffineFold
Getter --> Fold
AffineFold --> Fold
Traversal --> Fold
%% MultiFocus[F] is a composition sink with five sub-shapes
%% selected by F: AlgLens (F: Functor / Foldable / Traverse),
%% Kaleidoscope (F: Apply aggregates), Grate (F = Function1[X0, *]),
%% PowerSeries (F = PSVec, the Traversal.each carrier), and
%% FixedTraversal[N] (F = Function1[Int, *] for the .{two,three,four}
%% factories). Inbound bridges from every classical family land
%% here; the only outbound is to Setter via multifocus2setter.
%% Dotted edges mark *degraded* conversions where the result loses
%% information the original carrier carried.
Iso --> MultiFocus["MultiFocus[F]"]
Optional -.-> MultiFocus
MultiFocus --> Setter
MultiFocus -.-> Fold
%% Traversal.each is the F = PSVec sub-shape of MultiFocus[F]; drawn
%% as Traversal in the lattice for legibility.
Traversal --> MultiFocus
click Iso "#iso"
click Lens "#lens"
click Prism "#prism"
click Optional "#optional"
click Traversal "#traversal"
click Setter "#setter"
click Getter "#getter"
click AffineFold "#affinefold"
click Fold "#fold"
click MultiFocus "#multifocus"
How to read the diagram in practice:
- Same-family compose: result stays in that family.
Lens ∘ Lens = Lens,Prism ∘ Prism = Prism,Iso ∘ Iso = Iso. - Cross-family compose: walk down from each input until they
meet.
Lens ∘ Prismwalks Lens → Optional and Prism → Optional, meet atOptional.Iso ∘ Setterwalks Iso → Lens → Optional → Traversal → Setter, meet atSetter. - Read-only families on the right branch absorb their read-write parents. Composing into a Getter / AffineFold / Fold drops the write side; the result stays read-only.
- Solid edges = native, fused, or
Composer-resolved. Dotted edges = degraded conversion that loses information (documented in the MultiFocus section).
The full cell-by-cell composition matrix lives in
docs/research/2026-04-23-composition-gap-analysis.md;
that's the source of truth — the lattice above is the geometric view
of the same data. Post-fold the matrix is 12×12 with 17 U cells
(down from 145 U cells across the 14×14 pre-fold matrix).
The standalone Review family sits outside this tree — it
deliberately doesn't extend Optic (no read side to fit the
trait's to contract) and lives in its own section below.
import dev.constructive.eo.optics.{Lens, Optic}
import dev.constructive.eo.optics.Optic.*
import dev.constructive.eo.data.Forgetful.given // Accessor[Forgetful] — powers .get on Iso / Getter
import dev.constructive.eo.data.Forget.given // ForgetfulFunctor / Fold / Traverse for Forget[F] carriers
Every page here shows optics constructed by hand. For the
macro-derived lens[S](_.field) / prism[S, A] flavour, see
Generics.
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)
// res0: Int = 30
ageL.replace(31)(alice)
// res1: Person = Person(name = "Alice", age = 31)
ageL.modify(_ + 1)(alice)
// res2: 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.
Grate
The v1 Grate carrier (paired encoding (A, X => A), classical
shape ((S => A) => B) => T for distributive / Naperian rebuilds)
was absorbed into the unified MultiFocus[F] carrier pre-0.1.0
as MultiFocus[Function1[X0, *]]. The Grate-shaped factories ship
as MultiFocus.tuple[T <: Tuple, A] (homogeneous-tuple uniform
rewrite), MultiFocus.representable[F: Representable, A]
(arbitrary Naperian rebuild), and MultiFocus.representableAt
(representative-index variant).
See the MultiFocus reference for the unified treatment and Cookbook → Recipe A for a worked example of the absorbed Grate-shape.
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))
// res3: Either[Shape, Circle] = Right(Circle(1.0))
circleP.to(Shape.Square(2.0))
// res4: 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))
// res5: Shape = Circle(2.0)
circleP.modify(c => Shape.Circle(c.r * 2))(Shape.Square(2.0))
// res6: Shape = Square(2.0)
For auto-derivation on enums / sealed traits / union types see
prism[S, A] in Generics.
Iso
An Iso[S, A] is a bijection — every S round-trips to exactly
one A and back. Carrier: Forgetful (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"))
// res7: PersonPair = PersonPair(age = 30, name = "Alice")
pairIso.reverseGet(PersonPair(30, "Alice"))
// res8: Tuple2[Int, String] = (30, "Alice")
Optional
An Optional[S, A] focuses a conditionally-present field —
an Option[A] field, a predicate-gated access, a
refinement-style narrowing. Carrier: Affine.
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 construction
See AffineFold below. Optional.readOnly and
Optional.selectReadOnly are aliases that delegate to
AffineFold.apply / AffineFold.select — kept for users coming
from the "read-only Optional" mental model.
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))
// res11: Option[Int] = Some(20)
adultAge.getOption(Adult(15))
// res12: Option[Int] = None
AffineFold.select(p) is the filtering variant:
val evenAF = AffineFold.select[Int](_ % 2 == 0)
evenAF.getOption(4)
// res13: Option[Int] = Some(4)
evenAF.getOption(3)
// res14: Option[Int] = None
Narrow an existing Optional or Prism to its read-only
projection via AffineFold.fromOptional / AffineFold.fromPrism —
both return an AffineFold[S, A] that holds the matcher but
discards the write / build path.
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.fromOptional.
Specialisation. AffineFold.apply picks X = (Unit, Unit)
rather than the (Unit, S) shape a full Optional would use:
the Hit branch never needs to store the source S, since
from throws its input away. Saves one reference slot per
Affine.Hit allocation on every read.
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)))
// res15: SetterConfig = SetterConfig(Map("a" -> 2, "b" -> 3))
lens.andThen(setter) works — a Lens to a focus, then a Setter
that writes into it. setter.andThen(setter) also works:
SetterF ships an AssociativeFunctor[SetterF, Xo, Xi] instance
(SetterF.assocSetterF) with Z = (Fst[Xo], Snd[Xi]). The
deferred-modify semantic fits the protocol once you observe that
composeTo only needs to seed (outer-source, identity[C]) (no
inner.to call required, since SetterF's continuation is structurally
identity at every canonical construction site); composeFrom extracts
the user's c2d from the post-map continuation and applies it
through inner.from then outer.from. The standard
Optic.andThen[SetterF] resolution picks the instance up
transparently.
import dev.constructive.eo.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"))
// res16: Holder = Holder(box = Box(11), tag = "tag")
There is no Composer[SetterF, _] outbound — Setter remains a
read-side terminal, so to escape a SetterF chain into a Forget /
MultiFocus / Lens you have to restructure with the Setter on the
inside.
Getter
A Getter[S, A] is the read-only counterpart to Setter — a
pure projection. Carrier: Forgetful with T = Unit.
import dev.constructive.eo.optics.Getter
val nameLen = Getter[Person, Int](_.name.length)
nameLen.get(Person("Alice", 30))
// res17: Int = 5
Getter → Getter doesn't compose via Optic.andThen today
(Getter's T = Unit mismatches the outer B slot). For a
deeper read, compose a Lens chain and call .get on the
composed lens.
Fold
A Fold[F, A] summarises every element of a Foldable[F] via
Monoid[M]. 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))
// res18: Int = 6
listFold.foldMap((i: Int) => i * i)(List(1, 2, 3))
// res19: Int = 14
Fold.select(p) narrows to elements matching a predicate:
val positive = Fold.select[Int](_ > 0)
positive.foldMap(identity[Int])(3)
// res20: Int = 3
positive.foldMap(identity[Int])(-3)
// res21: Int = 0
Review
A Review[S, A] is the reverse-only counterpart to Getter —
it wraps an A => S build function. Unlike the other families,
Review does not extend Optic (the Optic trait requires
an observing to that a pure review has none of); it's a
standalone type with its own composition.
import dev.constructive.eo.optics.Review
val someIntR = Review[Option[Int], Int](Some(_))
someIntR.reverseGet(42)
// res22: Option[Int] = Some(42)
Compose by composing the underlying A => S functions directly:
val lengthR = Review[Int, String](_.length)
val someLen = Review[Option[Int], String](
s => someIntR.reverseGet(lengthR.reverseGet(s))
)
someLen.reverseGet("hello")
// res23: Option[Int] = Some(5)
Two factory methods pull the natural build direction out of an
Iso or a Prism — aliased as ReversedLens and ReversedPrism
for users who expect to find those names next to the rest of
the optics reference:
import dev.constructive.eo.optics.{BijectionIso, MendTearPrism, ReversedLens, ReversedPrism}
val doubleIso =
BijectionIso[Int, Int, Int, Int](_ * 2, _ / 2)
val revIso = ReversedLens(doubleIso)
val somePrism = new MendTearPrism[Option[Int], Option[Int], Int, Int](
tear = {
case Some(n) => Right(n)
case other => Left(other)
},
mend = Some(_),
)
val revPrism = ReversedPrism(somePrism)
revIso.reverseGet(5)
// res24: Int = 2
revPrism.reverseGet(7)
// res25: Option[Int] = Some(7)
ReversedLens only accepts a bijective Lens (an
BijectionIso). A general Lens doesn't carry enough
information to reconstruct its source from the focus alone —
for that, construct a Review directly with your own
A => S.
Traversal
A Traversal is the multi-focus modify optic — map over every
element of a container. Single carrier:
Traversal.each[F, A]/Traversal.pEach[F, A, B]— carrierMultiFocus[PSVec]. Supports.modify/.replace(Functor[PSVec]),.foldMap(Foldable[PSVec]),.modifyA/.all(Traverse[PSVec]), and.andThenwith downstream optics through the sharedMultiFocus[PSVec]AssociativeFunctor(mfAssocPSVec). Linear scaling; overhead over a naivecopy/mapruns at 2-3× for dense chains (Lens → Traversal → Lens) and ~5× for the Prism miss-branch shape, amortising toward the lower end as the traversed- collection size grows (the benchmarks sweep sizes 4 / 32 / 256 / 1024). Internal machinery: theMultiFocusSingleton(AlwaysHit, for morphed Lenses) andMultiFocusPSMaybeHit(MaybeHit, for morphed Prisms / Optionals) fast-paths collect into pre-sized flat arrays without per-element wrapper allocations.
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))
// res26: List[Int] = List(2, 3, 4)
listEach.foldMap(identity[Int])(List(1, 2, 3)) // sum
// res27: 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"),
)))
// res28: Owner = Owner(
// List(
// Phone(isMobile = true, number = "555-0001"),
// Phone(isMobile = false, number = "555-0002")
// )
// )
See the PowerSeries benchmark notes for the cost tradeoff.
Composer: Iso as the inner of Traversal.each
Traversal.each[T, A].andThen(iso) composes cleanly. The direct
Composer[Forgetful, MultiFocus[F]] given (forgetful2multifocus)
ships in dev.constructive.eo.data.MultiFocus and takes priority
over any transitive path (Forgetful → Tuple2 → MultiFocus[PSVec]
or Forgetful → Either → MultiFocus[PSVec]) that would otherwise
be ambiguous.
Same story for Iso as the inner of an Optional (Affine carrier)
— direct Composer[Forgetful, Affine] ships beside the carrier.
Earlier revisions of cats-eo required an explicit .morph[Tuple2]
step for these chains; post-Unit 16 it's a one-hop .andThen call
with no ceremony.
MultiFocus
MultiFocus[F][X, A] = (X, F[A]) — a structural leftover paired
with an F-shaped focus. The unified successor of five v1 carriers
(AlgLens[F], Kaleidoscope, Grate, PowerSeries,
FixedTraversal[N]); each is now a sub-shape selected by F.
The carrier is just a pair — it ships no typeclass machinery of
its own and inherits whatever F brings. Surface lights up by
typeclass: .modify (Functor[F]), .foldMap (Foldable[F]),
.modifyA (Traverse[F]), .collectMap (Functor[F]),
.collectList (List-only cartesian), .at(i) (Representable[F]),
same-carrier .andThen (per-F AssociativeFunctor instances).
See the MultiFocus reference for the unification
narrative, the typeclass-gated capability matrix, and the
composability profile (inbound bridges from every classical family,
single outbound to SetterF, structurally-rejected
MultiFocus → Forgetful / MultiFocus → Forget[G]). Worked
examples ground each absorbed sub-shape in the cookbook:
Recipe A — Grate-shape,
Recipe B — Kaleidoscope-shape,
Recipe C — PowerSeries downstream composition.
Composition limits
Beyond the MultiFocus-outbound sink documented above, several categories of pair are either intentionally not bridged in 0.1.0 or only bridged through a user-opt-in side-channel. Each entry below states the structural shape, the rationale, and the idiomatic workaround or opt-in:
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 Forget's classifier
representation without dropping its rebuild data, and 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) (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 read-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) —
post-fold these factories produce MultiFocus[Function1[Int, *]]-carrier
optics, so they inherit the absorbed-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 — same
constraint as the v1 Grate).
The full taxonomy with cell-by-cell rationale lives in
docs/research/2026-04-23-composition-gap-analysis.md.