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:

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.