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.
import eo.optics.{Lens, Optic}
import eo.optics.Optic.*
import eo.data.Forgetful.given // Accessor[Forgetful] — powers .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.
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. To cross into an
Optional / Setter / PowerSeries chain, morph the Lens
first via .morph[Affine] / .morph[SetterF] /
.morph[PowerSeries].
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 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 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 eo.data.Affine
import 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 note: lens.andThen(optional) needs the Lens to
carry Affine; use someLens.morph[Affine].andThen(optional).
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 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)))
// res11: SetterConfig = SetterConfig(Map("a" -> 2, "b" -> 3))
Getter
A Getter[S, A] is the read-only counterpart to Setter — a
pure projection. Carrier: Forgetful with T = Unit.
import eo.optics.Getter
val nameLen = Getter[Person, Int](_.name.length)
nameLen.get(Person("Alice", 30))
// res12: 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 eo.optics.Fold
val listFold = Fold[List, Int]
listFold.foldMap(identity[Int])(List(1, 2, 3))
// res13: Int = 6
listFold.foldMap((i: Int) => i * i)(List(1, 2, 3))
// res14: Int = 14
Fold.select(p) narrows to elements matching a predicate:
val positive = Fold.select[Int](_ > 0)
positive.foldMap(identity[Int])(3)
// res15: Int = 3
positive.foldMap(identity[Int])(-3)
// res16: Int = 0
Traversal
A Traversal is the multi-focus modify optic — map over every
element of a container. Two carriers coexist:
Traversal.each[F, A, B]— carrierForget[F], linear time, no downstream optic composition.Traversal.powerEach[F, A]— carrierPowerSeries, supports.andThenwith downstream optics, pays a super-linear cost for the flexibility.
Use each by default:
import eo.optics.Traversal
val listEach = Traversal.each[List, Int, Int]
listEach.modify(_ + 1)(List(1, 2, 3))
// res17: List[Int] = List(2, 3, 4)
Reach for powerEach when the chain continues past the
traversal — e.g. "for every phone, toggle isMobile":
import eo.data.PowerSeries
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))
.morph[PowerSeries]
.andThen[Phone, Phone](Traversal.powerEach[List, Phone])
.andThen(
Lens[Phone, Boolean](_.isMobile, (p, m) => p.copy(isMobile = m))
.morph[PowerSeries]
)
ownerAllPhonesMobile.modify(!_)(Owner(List(
Phone(isMobile = false, "555-0001"),
Phone(isMobile = true, "555-0002"),
)))
// res18: Owner = Owner(
// List(
// Phone(isMobile = true, number = "555-0001"),
// Phone(isMobile = false, number = "555-0002")
// )
// )
See the PowerSeries benchmark notes for the cost tradeoff.