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:
- Same-family compose stays in that family:
Lens ∘ Lens = Lens,Prism ∘ Prism = Prism,Iso ∘ Iso = Iso. - Cross-family compose walks down from each input to where they
meet:
Lens ∘ Prism→Affine;Iso ∘ Setter→Setter. - The bi-directional spine (Iso, Lens, Prism, Affine, MultiFocus) carries both a read and a write side. Single-direction optics keep only one: Getter reads, Setter writes, Review builds.
- Composing a bi-directional optic into Getter or Setter drops the other side — the result is single-direction.
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.