Cookbook

Runnable patterns for the questions that come up most often. Every fence is compiled by mdoc against the current library version, and every recipe cites the source — Penner's Optics By Example, Monocle docs, Haskell lens tutorial, circe-optics, or cats-eo itself where the surface is novel.

Recipes are grouped by theme. The order inside each theme moves from the most familiar framing to the most cats-eo-unique capability; skim the headings for a jumping-off point.

import dev.constructive.eo.optics.{Iso, Lens, Optic, Optional, Prism, Traversal}
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.MultiFocus.given   // Functor / Foldable / Traverse for MultiFocus[PSVec] (post-fold)

Theme A — Product editing

Edit a deeply-nested coordinate

The canonical Atom/Molecule walk every Haskell tutorial opens with — increment a deeply-buried leaf through four composition hops:

case class Point(x: Int, y: Int)
case class Atom(point: Point, mass: Double)
case class Molecule(atoms: List[Atom])

val everyX =
  Lens[Molecule, List[Atom]](_.atoms, (m, as) => m.copy(atoms = as))
    .andThen(Traversal.each[List, Atom])
    .andThen(Lens[Atom, Point](_.point, (a, p) => a.copy(point = p)))
    .andThen(Lens[Point, Int](_.x, (p, x) => p.copy(x = x)))
val water = Molecule(List(
  Atom(Point(0, 0), 1.0),
  Atom(Point(1, 0), 16.0),
  Atom(Point(2, 0), 1.0),
))
// water: Molecule = Molecule(
//   List(
//     Atom(point = Point(x = 0, y = 0), mass = 1.0),
//     Atom(point = Point(x = 1, y = 0), mass = 16.0),
//     Atom(point = Point(x = 2, y = 0), mass = 1.0)
//   )
// )
everyX.modify(_ + 1)(water).atoms.map(_.point.x)
// res0: List[Int] = List(1, 2, 3)

Same-carrier Lens hops fuse through Tuple2's AssociativeFunctor; the cross-carrier hop into PowerSeries at .each happens without a manual .morph call. For macro-derived versions of these Lenses see Generics.

Source: Gonzalez — Control.Lens.Tutorial, https://hackage.haskell.org/package/lens-tutorial-1.0.5/docs/Control-Lens-Tutorial.html.

Virtual-field Iso — Celsius ↔ Fahrenheit facade

Expose a fahrenheit handle on a Temperature whose underlying representation is Celsius. Callers get and reverseGet without knowing which unit the type stores:

import dev.constructive.eo.optics.BijectionIso

case class Temperature(toC: Double)

val celsius = BijectionIso[Temperature, Temperature, Double, Double](
  _.toC,
  Temperature(_),
)
val c2f = BijectionIso[Double, Double, Double, Double](
  c => c * 9.0 / 5.0 + 32.0,
  f => (f - 32.0) * 5.0 / 9.0,
)

val fahrenheit = celsius.andThen(c2f)
fahrenheit.get(Temperature(100.0))
// res1: Double = 212.0
fahrenheit.reverseGet(32.0)
// res2: Temperature = Temperature(0.0)
fahrenheit.modify(_ + 9.0)(Temperature(0.0))
// res3: Temperature = Temperature(5.0)

BijectionIso.andThen(BijectionIso) is fused through the concrete-subclass override — no per-hop typeclass dispatch — so the facade-as-public-API refactor story doesn't cost anything at runtime. See Iso for the carrier details.

Source: Penner — Virtual Record Fields Using Lenses, https://chrispenner.ca/posts/virtual-fields.

Multi-field NamedTuple focus (cats-eo-unique)

Focus several case-class fields at once and update them atomically. The lens[S](_.a, _.b, ...) macro returns a Lens whose focus is a Scala 3 NamedTuple in selector order — no Monocle analogue:

import dev.constructive.eo.generics.lens

case class OrderItem(sku: String, quantity: Int, price: Double)

val qtyAndPrice = lens[OrderItem](_.quantity, _.price)
val order = OrderItem("abc-123", 3, 9.99)
// order: OrderItem = OrderItem(sku = "abc-123", quantity = 3, price = 9.99)

// Atomic multi-field update: see both fields as a NamedTuple,
// return the NamedTuple with new values.
qtyAndPrice.modify { nt =>
  (quantity = nt.quantity * 2, price = nt.price * 0.9)
}(order)
// res4: OrderItem = OrderItem(sku = "abc-123", quantity = 6, price = 8.991)

The selector order determines the NamedTuple shape, so lens[OrderItem](_.price, _.quantity) would produce a focus whose .price field comes first. Partial cover → SimpleLens; full cover flips to a BijectionIso (next recipe). See Generics → Multi-field Lens for the full treatment.

Source: cats-eo internal.

Full-cover Iso upgrade (cats-eo-unique)

When the selector set spans every case field of S, the macro silently upgrades from SimpleLens to BijectionIso. The user writes a Lens and the macro delivers a round-trip bijection — handy at serialization boundaries:

case class ProductCode(value: String)

// arity-1 wrapper: single-selector full-cover returns BijectionIso,
// not SimpleLens. The focus type is the Scala 3 NamedTuple
// NamedTuple[("value",), (String,)] — selector name preserved.
val codeIso = lens[ProductCode](_.value)
val pc = ProductCode("abc-42")
// pc: ProductCode = ProductCode("abc-42")
val nt = codeIso.get(pc)
// nt: NamedTuple[*:["value", EmptyTuple], *:[String, EmptyTuple]] = Tuple1(
//   "abc-42"
// )
nt.value
// res5: String = "abc-42"
codeIso.reverseGet((value = "zzz-99"))
// res6: ProductCode = ProductCode("zzz-99")
codeIso.modify(n => (value = n.value.toUpperCase))(pc)
// res7: ProductCode = ProductCode("ABC-42")

The return-type switch is deliberate: on full cover there's no structural complement left, so the lens degenerates into a bijection. Same holds for multi-field full cover — see Generics → Full-cover Iso.

Source: cats-eo internal.

Theme B — Sum-branch access

Prism + Lens — branch into a case, then edit inside

Branch into a specific enum case and modify a field inside the hit branch; the miss branch passes through unchanged. The pattern covers both the "update one variant" discriminated-union use case and the F#-style expression-tree AST case-dispatch:

enum Expr:
  case Var(name: String)
  case App(f: Expr, x: Expr)
  case Lam(bind: String, body: Expr)

val varP = Prism[Expr, Expr.Var](
  {
    case v: Expr.Var => Right(v)
    case other       => Left(other)
  },
  identity,
)

val varName =
  varP.andThen(Lens[Expr.Var, String](_.name, (v, n) => v.copy(name = n)))
// Hit branch: the Var's name is uppercased.
varName.modify(_.toUpperCase)(Expr.Var("x"))
// res8: Expr = Var("X")

// Miss branch: passes through, the Lam is untouched.
varName.modify(_.toUpperCase)(Expr.Lam("y", Expr.Var("y")))
// res9: Expr = Lam(bind = "y", body = Var("y"))

The Prism (Either) and Lens (Tuple2) compose directly; .andThen summons Composer[Either, Tuple2] via Morph.bothViaAffine under the hood (both sides lift into Affine, then bridge). Same story for Scala 3 enums derived through the prism[S, A] macro — see Generics → prism[S, A].

Source: Baeldung — Monocle Optics, https://www.baeldung.com/scala/monocle-optics; framing from Wlaschin — Domain Modeling Made Functional, ch. 4, https://pragprog.com/titles/swdddf/domain-modeling-made-functional/.

Theme C — Collection walks

each — the composable traversal

cats-eo ships a single Traversal carrier — Traversal.each. Map over every element of a container, fold via .foldMap, and chain further optics past the traversal in the same call:

import cats.instances.list.given

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

each shines when the chain continues past the traversal — the same map, then drill further into each element:

case class Dial(isMobile: Boolean, number: String)
case class Subscriber(phones: List[Dial])

val everyMobile =
  Lens[Subscriber, List[Dial]](_.phones, (s, ps) => s.copy(phones = ps))
    .andThen(Traversal.each[List, Dial])
    .andThen(Lens[Dial, Boolean](_.isMobile, (d, m) => d.copy(isMobile = m)))
everyMobile.modify(!_)(Subscriber(List(
  Dial(isMobile = false, "555-0001"),
  Dial(isMobile = true,  "555-0002"),
)))
// res12: Subscriber = Subscriber(
//   List(
//     Dial(isMobile = true, number = "555-0001"),
//     Dial(isMobile = false, number = "555-0002")
//   )
// )

The cost tradeoff is documented in Optics → Traversal and the PowerSeries benchmarks: each runs 2-3× over a naive copy/map for dense Lens-Traversal-Lens chains, amortising toward 1.9× as the container size grows.

Source: Penner — Optics By Example ch. 7 (Simple Traversals), https://leanpub.com/optics-by-example/; Gonzalez — Control.Lens.Tutorial, https://hackage.haskell.org/package/lens-tutorial-1.0.5/docs/Control-Lens-Tutorial.html.

Sparse traversal over a Prism (cats-eo-unique)

For every element of a container, drill through a Prism and modify only the hit branch — Err elements pass through unchanged, Ok elements get their value bumped. This is the "sparse traversal" pattern that's genuinely annoying to hand-roll, and the optic spelling is a one-liner:

import scala.collection.immutable.ArraySeq

enum Result:
  case Ok(value: Int)
  case Err(reason: String)

val okP = Prism[Result, Result.Ok](
  {
    case o: Result.Ok => Right(o)
    case other        => Left(other)
  },
  identity,
)

val bumpOks =
  Traversal.each[ArraySeq, Result]
    .andThen(okP)
    .andThen(Lens[Result.Ok, Int](_.value, (o, v) => o.copy(value = v)))
bumpOks.modify(_ + 1)(
  ArraySeq(Result.Ok(10), Result.Err("nope"), Result.Ok(20))
)
// res13: ArraySeq[Result] = ArraySeq(Ok(11), Err("nope"), Ok(21))

Performance: the PowerSeriesPrismBench suite measures this shape at ~5× over a hand-rolled naive loop — and that's the published worst case for each, not the typical one. The per-benchmark curve lives in benchmarks → PowerSeries with Prism inner.

Source: Penner — Optics By Example ch. 8 (Traversal Actions) + ch. 10 (Missing Values), https://leanpub.com/optics-by-example/.

Theme D — JSON editing and tree walks

The JSON arc — edit leaf, edit array, diagnose

One vignette in three acts: walk a deep JSON path and modify a leaf without decoding the siblings; walk every element of a nested array; and observe why an edit was a silent no-op. The Ior failure-flow diagram covers the full decision tree.

Act 1 — edit one leaf deep in a JSON tree (no decode)

codecPrism[S] walks circe's Json directly; only the focused leaf is materialised as A:

import dev.constructive.eo.circe.codecPrism
import io.circe.Codec
import io.circe.syntax.*
import hearth.kindlings.circederivation.KindlingsCodecAsObject

case class UserAddress(street: String, zip: Int)
object UserAddress:
  given Codec.AsObject[UserAddress] = KindlingsCodecAsObject.derive

case class SiteUser(name: String, address: UserAddress)
object SiteUser:
  given Codec.AsObject[SiteUser] = KindlingsCodecAsObject.derive

val userStreet = codecPrism[SiteUser].address.street
val userJson = SiteUser("Alice", UserAddress("Main St", 12345)).asJson
// userJson: Json = JObject(
//   object[name -> "Alice",address -> {
//   "street" : "Main St",
//   "zip" : 12345
// }]
// )
userStreet.modifyUnsafe(_.toUpperCase)(userJson).noSpacesSortKeys
// res14: String = "{\"address\":{\"street\":\"MAIN ST\",\"zip\":12345},\"name\":\"Alice\"}"

The JsonPrismBench suite documents a 2× speedup at every depth over the decode / .copy / re-encode path. circe-optics' analogous root.user.address.street surface forces a full decode per level; cats-eo's cursor walk does not.

Act 2 — edit every element of a JSON array

Walk an array without materialising it as a Scala collection; only the focused leaf of each element is decoded. The .each step splits the Prism into a JsonTraversal:

case class Item(name: String, price: Double)
object Item:
  given Codec.AsObject[Item] = KindlingsCodecAsObject.derive

case class Basket(owner: String, items: Vector[Item])
object Basket:
  given Codec.AsObject[Basket] = KindlingsCodecAsObject.derive
val basket = Basket("Alice", Vector(Item("apple", 1.0), Item("pear", 2.0)))
// basket: Basket = Basket(
//   owner = "Alice",
//   items = Vector(
//     Item(name = "apple", price = 1.0),
//     Item(name = "pear", price = 2.0)
//   )
// )
val everyItemName = codecPrism[Basket].items.each.name
// everyItemName: JsonTraversal[String] = dev.constructive.eo.circe.JsonTraversal@1fe094b6

everyItemName.modifyUnsafe(_.toUpperCase)(basket.asJson).noSpacesSortKeys
// res15: String = "{\"items\":[{\"name\":\"APPLE\",\"price\":1.0},{\"name\":\"PEAR\",\"price\":2.0}],\"owner\":\"Alice\"}"

Per-element failures to decode accumulate into Ior.Both(chain, partialJson) on the default surface — next act.

Act 3 — diagnose a silent edit no-op

A deep .modify appears to do nothing — which path step refused? The *Unsafe methods preserve the pre-v0.2 silent behaviour, but the default modify returns Ior[Chain[JsonFailure], Json] so the diagnostic is observable:

import cats.data.Ior
import io.circe.Json

// A stump Json missing the `.address` field altogether.
val stump = Json.obj("name" -> Json.fromString("Alice"))
userStreet.modify(_.toUpperCase)(stump)
// res16: Ior[Chain[JsonFailure], Json] = Both(
//   a = Singleton(PathMissing(Field("address"))),
//   b = JObject(object[name -> "Alice"])
// )

The Ior.Both(chain, json) carries both the pre-v0.2 behaviour (the unchanged Json) and the diagnostic (the chain). Fold the chain into a readable message, route each case to its own log stream, or collapse on the getOrElse escape hatch — the full cascade is Theme I below.

Source: cats-eo internal (JsonPrism, JsonTraversal); related to circe-optics' root.* idiom https://circe.github.io/circe/optics.html. Structured-failure inspiration from cats' Ior typeclass and DecodingFailure.history.

jq-style path + predicate

"For every item whose price > 100, uppercase its name" — expressible in jq as .items[] | select(.price > 100) | .name |= ascii_upcase. The cats-eo rendering reaches through the multi-field focus (Scala 3 NamedTuple) and branches inside the modify lambda:

type NamePrice = NamedTuple.NamedTuple[("name", "price"), (String, Double)]
given Codec.AsObject[NamePrice] = KindlingsCodecAsObject.derive

val premiumNames =
  codecPrism[Basket]
    .items
    .each
    .fields(_.name, _.price)
val mixedBasket = Basket(
  "Alice",
  Vector(
    Item("apple",  1.0),
    Item("lobster", 150.0),
    Item("truffle", 300.0),
  ),
).asJson
// mixedBasket: Json = JObject(
//   object[owner -> "Alice",items -> [
//   {
//     "name" : "apple",
//     "price" : 1.0
//   },
//   {
//     "name" : "lobster",
//     "price" : 150.0
//   },
//   {
//     "name" : "truffle",
//     "price" : 300.0
//   }
// ]]
// )

premiumNames
  .modifyUnsafe { nt =>
    if nt.price > 100.0 then
      (name = nt.name.toUpperCase, price = nt.price)
    else nt
  }(mixedBasket)
  .noSpacesSortKeys
// res17: String = "{\"items\":[{\"name\":\"apple\",\"price\":1.0},{\"name\":\"LOBSTER\",\"price\":150.0},{\"name\":\"TRUFFLE\",\"price\":300.0}],\"owner\":\"Alice\"}"

A tighter spelling via filtered / selected is not in cats-eo 0.1.0 — track that as AffineFold-adjacent work for the 0.2 cycle. Today the predicate inlines inside the modify lambda, which is honest about the shape of the work.

Source: Penner — Generalizing 'jq' and Traversal Systems, https://chrispenner.ca/posts/traversal-systems.

Recursive rename over a user-defined Tree

Walk a user-supplied tree type — the library doesn't need to ship a tree carrier when the user's type already has cats.Traverse. Bring your own Traverse and each picks it up:

import cats.Traverse
import cats.Applicative
import cats.syntax.functor.*

enum Tree[+A]:
  case Leaf(value: A)
  case Branch(left: Tree[A], right: Tree[A])

// Hand-rolled Traverse[Tree] — in real code reach for a derivation
// or cats.Reducible instance on your own ADT.
given Traverse[Tree] with
  def foldLeft[A, B](fa: Tree[A], b: B)(f: (B, A) => B): B = fa match
    case Tree.Leaf(a)       => f(b, a)
    case Tree.Branch(l, r)  => foldLeft(r, foldLeft(l, b)(f))(f)
  def foldRight[A, B](fa: Tree[A], lb: cats.Eval[B])(f: (A, cats.Eval[B]) => cats.Eval[B]): cats.Eval[B] =
    fa match
      case Tree.Leaf(a)       => f(a, lb)
      case Tree.Branch(l, r)  => foldRight(l, foldRight(r, lb)(f))(f)
  def traverse[G[_]: Applicative, A, B](fa: Tree[A])(f: A => G[B]): G[Tree[B]] =
    fa match
      case Tree.Leaf(a)       => f(a).map(Tree.Leaf(_))
      case Tree.Branch(l, r)  =>
        Applicative[G].map2(traverse(l)(f), traverse(r)(f))(Tree.Branch(_, _))

val renameLeaves = Traversal.each[Tree, String]
val tree: Tree[String] =
  Tree.Branch(
    Tree.Leaf("a"),
    Tree.Branch(Tree.Leaf("b"), Tree.Leaf("c")),
  )
// tree: Tree[String] = Branch(
//   left = Leaf("a"),
//   right = Branch(left = Leaf("b"), right = Leaf("c"))
// )
renameLeaves.modify(_.toUpperCase)(tree)
// res18: Tree[String] = Branch(
//   left = Leaf("A"),
//   right = Branch(left = Leaf("B"), right = Leaf("C"))
// )

Works identically for any other Traverse[F] — trees, rose trees, non-empty lists, user ADTs. The Iris classifier follow-on (algebraic lens over a labelled dataset) is tracked separately in docs/plans/2026-04-22-002-feat-iris-classifier-example.md.

Source: Stanislav Glebik — RefTree talk, https://stanch.github.io/reftree/docs/talks/Visualize/.

Theme E — Algebraic / classifier

MultiFocus[F] is the unified successor of five v1 carriers (AlgLens[F], Kaleidoscope, Grate, PowerSeries, FixedTraversal[N]). Every former carrier becomes a sub-shape selected by F. The three recipes below cover the prototypical shapes — see the MultiFocus reference for the full unification narrative.

Recipe A — Prototypical Grate-shape via MultiFocus.tuple

The "broadcast a uniform A => B over a homogeneous tuple" idiom — the absorbed-Grate sub-shape MultiFocus[Function1[Int, *]]. Same optic value carries .modify (uniform pointwise rewrite) and .modifyA[G] (effectful pointwise rewrite that short-circuits on G's applicative).

import cats.instances.function.given  // Functor[Function1[Int, *]] for .modify
import dev.constructive.eo.data.MultiFocus
import dev.constructive.eo.data.MultiFocus.given
import dev.constructive.eo.data.MultiFocus.{collectList, collectMap}

// Three colour channels — each a Double in [0.0, 1.0].
val rgbMF = MultiFocus.tuple[(Double, Double, Double), Double]
val violet = (0.5, 0.0, 0.5)
// violet: Tuple3[Double, Double, Double] = (0.5, 0.0, 0.5)

// Brighten all three channels uniformly: the same `A => B` runs at
// every slot. Same shape as the v1 Grate.modify.
rgbMF.modify(c => (c * 1.4).min(1.0))(violet)
// res19: Tuple3[Double, Double, Double] = (0.7, 0.0, 0.7)

// Replace every slot with the same constant — the broadcast pattern.
rgbMF.replace(0.0)(violet)
// res20: Tuple3[Double, Double, Double] = (0.0, 0.0, 0.0)

The carrier is MultiFocus[Function1[Int, *]]Function1[Int, *] is the Naperian shape over a finite index set. .modify runs the same closure at every index; .replace(b) fills every slot with b. The absorbed-Grate sub-shape does NOT admit .modifyA[G] because Function1[Int, *] lacks Traverse in cats — short-circuiting effectful traversal needs a Foldable + Functor shape that the representable encoding doesn't supply. Reach for MultiFocus.apply[List, Double] (F = List) when the use case needs .modifyA.

This is the absorbed-Grate sub-shape from the carrier consolidation; MultiFocus.representable[F: Representable, A] covers the same shape over arbitrary distributive Fs (tuple-of-pair (A, A), user-defined Naperian containers, etc.).

Source: Penner — Grate: yet another optic, https://chrispenner.ca/posts/grate.

Recipe B — Prototypical Kaleidoscope-shape via .collectMap / .collectList

The "applicative-aware aggregation" idiom — the absorbed-Kaleidoscope sub-shape. The same MultiFocus[F] optic carries two .collect* flavours, and the user picks whichever matches the aggregation shape they want:

import cats.data.ZipList

val zipMF = MultiFocus.apply[ZipList, Double]
val intListMF = MultiFocus.apply[List, Int]
// (1) MultiFocus[ZipList] + .collectMap — column-wise mean broadcast
//     back across positions. The aggregator sees the whole ZipList,
//     returns one Double, Functor[ZipList].map fills it back at every
//     index. Length-preserving.
zipMF.collectMap[Double](zl => zl.value.sum / zl.value.size.toDouble)(
  ZipList(List(1.0, 2.0, 3.0, 4.0))
)
// res21: ZipList[Double] = cats.data.ZipList@b588d106

// (2) MultiFocus[List] + .collectList — cartesian / singleton output:
//     `List(agg(fa))`, regardless of input length. Reproduces the v1
//     Reflector[List] semantics at the call site without a typeclass.
intListMF.collectList(_.sum)(List(1, 2, 3, 4))
// res22: List[Int] = List(10)

// Same optic, .collectMap flavour: List collapses into the
// length-preserving (Functor-broadcast) shape — equivalent to the v1
// Reflector[ZipList] interpretation if it were applied to a List.
intListMF.collectMap[Int](_.sum)(List(1, 2, 3, 4))
// res23: List[Int] = List(10, 10, 10, 10)

When to reach for which .collect* flavour.

The post-fold story is the user picks per call site — the carrier-consolidation Q1 finding explains why no single derivation from Apply[F] covers both behaviours uniformly; the v1 Reflector[F] typeclass that picked one or the other per F was deleted.

Sources: Penner — Kaleidoscopes: lenses that never die, https://chrispenner.ca/posts/kaleidoscopes; Penner — Algebraic lenses, https://chrispenner.ca/posts/algebraic.

Recipe C — PowerSeries downstream composition (Lens → each → Lens)

The post-consolidation crown jewel. The absorbed-PowerSeries sub-shape MultiFocus[PSVec] carries an AssociativeFunctor[MultiFocus[PSVec], _, _] instance (mfAssocPSVec), so .andThen continues past Traversal.each into a downstream Lens. Pre-fold the deleted Traversal.forEach shape (Forget[T]-based) was terminal — a chain ending at .forEach(...) could not extend further.

case class Order(id: Int, name: String, total: Double)
case class Cart(owner: String, orders: List[Order])

val cartOrdersL =
  Lens[Cart, List[Order]](_.orders, (c, os) => c.copy(orders = os))

val orderNameL =
  Lens[Order, String](_.name, (o, n) => o.copy(name = n))

// Three hops, two carriers:
//   Cart →(Tuple2)→ List[Order] →(MultiFocus[PSVec])→ Order →(Tuple2)→ String
//
// The cross-carrier hops fire transparently:
//   - .andThen(Traversal.each[List, Order]) lifts the outer Lens
//     into MultiFocus[PSVec] via `tuple2multifocusPSVec`.
//   - .andThen(orderNameL) is the DOWNSTREAM hop — `tuple2multifocusPSVec`
//     fires again to lift orderNameL into MultiFocus[PSVec], and
//     mfAssocPSVec composes the two same-carrier optics.
val everyOrderName =
  cartOrdersL
    .andThen(Traversal.each[List, Order])
    .andThen(orderNameL)
val cart = Cart("Alice", List(
  Order(1, "apple",  1.0),
  Order(2, "pear",   2.0),
  Order(3, "lobster", 150.0),
))
// cart: Cart = Cart(
//   owner = "Alice",
//   orders = List(
//     Order(id = 1, name = "apple", total = 1.0),
//     Order(id = 2, name = "pear", total = 2.0),
//     Order(id = 3, name = "lobster", total = 150.0)
//   )
// )

// Modify every order's name through the chain.
everyOrderName.modify(_.toUpperCase)(cart)
// res24: Cart = Cart(
//   owner = "Alice",
//   orders = List(
//     Order(id = 1, name = "APPLE", total = 1.0),
//     Order(id = 2, name = "PEAR", total = 2.0),
//     Order(id = 3, name = "LOBSTER", total = 150.0)
//   )
// )

// Read-only escape via .foldMap — Foldable[PSVec] is shipped
// alongside the carrier instances.
everyOrderName.foldMap((s: String) => List(s))(cart)
// res25: List[String] = List("apple", "pear", "lobster")

The pre-fold contrast: Traversal.forEach[F, T](xs) was a Forget[T]-carrier optic — the leftover side carried no rebuild information, only the read-side aggregation, so chains like .forEach(...).andThen(orderNameL) were unreachable. The post-fold Traversal.each is MultiFocus[PSVec]-carrier; same read-side capability via .foldMap, plus the rebuild side that makes .andThen continue past it.

The mfAssocPSVec body is the absorbed PowerSeries.assoc verbatim — parallel-array AssocSndZ leftover, both MultiFocusSingleton (AlwaysHit, for morphed Lenses) and MultiFocusPSMaybeHit (MaybeHit, for morphed Prisms / Optionals) fast-paths preserved. The benchmark numbers in docs/research/2026-04-29-powerseries-fold-spike.md §3.4 show the post-fold carrier within ±5% of pre-fold PowerSeries at every size up to 1024.

Source: the post-fold composability story; benchmark discussion in Optics → Traversal and benchmarks → PowerSeries with downstream composition.

Theme F — Write-only / read-only escape

Review — reverse-only build path

Build a UserId from a UUID through a Review so tests and prod share one construction path — reverse-only, no observable read side:

import java.util.UUID
import dev.constructive.eo.optics.Review

case class UserId(value: UUID)

val userIdR: Review[UserId, UUID] = Review(UserId(_))
userIdR.reverseGet(UUID.fromString("00000000-0000-0000-0000-000000000001"))
// res26: UserId = UserId(00000000-0000-0000-0000-000000000001)

Name the "build-only" intent as first-class and team members stop reaching for bare smart constructors. Contrast with Getter (next recipe) for read-only. Review deliberately sits outside the Optic trait — its lack of a read side would force an artificial to member. See Optics → Review for the rationale.

Source: Penner — Optics By Example ch. 13, https://leanpub.com/optics-by-example/.

Getter — derive a read-only view

Expose a derived projection so callers can't try to write through it. Getter[S, A] wraps a pure S => A with T = Unit on the full optic trait — the mismatch with B statically rules out .modify:

import dev.constructive.eo.optics.Getter

case class Shopper(name: String, age: Int)

val nameInitial = Getter[Shopper, Char](_.name.head)
nameInitial.get(Shopper("Alice", 30))
// res27: Char = 'A'

See the composition note at Optics → GetterGetter.andThen(Getter) is handled by composing the underlying S => A functions directly rather than through Optic.andThen, because the T = Unit slot doesn't align with the outer B.

Source: Monocle — Focus docs, https://www.optics.dev/Monocle/docs/focus.

Setter — opaque bulk write

Encrypt every password in a UserDb without exposing the plaintext to the call site. Setter[S, A] writes but doesn't read — the carrier SetterF carries no observable read side:

import dev.constructive.eo.optics.Setter

case class Users(credentials: Map[String, String])

val encryptAll = Setter[Users, Users, String, String] { f => users =>
  users.copy(credentials = users.credentials.view.mapValues(f).toMap)
}
encryptAll.modify(pw => s"bcrypt($pw)")(
  Users(Map("alice" -> "s3cret", "bob" -> "hunter2"))
)
// res28: Users = Users(
//   Map("alice" -> "bcrypt(s3cret)", "bob" -> "bcrypt(hunter2)")
// )

Setter is a composition terminal: lens.andThen(setter) works, setter.andThen(...) does not. See the Setter section for the intentional asymmetry.

Source: Monocle — Optics docs, https://www.optics.dev/Monocle/docs/optics.

AffineFold — predicate-narrowed read

Return Some(age) only when the subject is an adult; no write-back. AffineFold is the 0-or-1 read-only shape with T = Unit:

import dev.constructive.eo.optics.AffineFold

case class Person(age: Int)

val adultAge: AffineFold[Person, Int] =
  AffineFold(p => Option.when(p.age >= 18)(p.age))
adultAge.getOption(Person(20))
// res29: Option[Int] = Some(20)
adultAge.getOption(Person(15))
// res30: Option[Int] = None

Narrow an existing Optional or Prism to its read-only projection via AffineFold.fromOptional / AffineFold.fromPrism — both discard the write / build path but keep the matcher. See Optics → AffineFold for the composition-corner note (direct .andThen off a Lens into an AffineFold doesn't type-check; narrow after composing).

Source: Penner — Optics By Example ch. 10 (Missing Values), https://leanpub.com/optics-by-example/.

Theme G — Composition

Three-family ladder: Iso → Lens → Traversal → Prism → Lens

One vignette exercises every Composer bridge cats-eo ships, with no explicit .morph calls anywhere. From a UserTuple (structurally the same as UserRecord), pull out the orders: List[Payment], filter to the Paid variant, and increment their amount:

final case class UserTuple(name: String, orders: List[Payment])
final case class UserRecord(name: String, orders: List[Payment])

enum Payment:
  case Paid(amount: Double)
  case Pending(amount: Double)
  case Cancelled

val userIso =
  Iso[UserTuple, UserTuple, UserRecord, UserRecord](
    ut => UserRecord(ut.name, ut.orders),
    ur => UserTuple(ur.name, ur.orders),
  )

val userOrders =
  Lens[UserRecord, List[Payment]](_.orders, (u, os) => u.copy(orders = os))

val paidP = Prism[Payment, Payment.Paid](
  {
    case p: Payment.Paid => Right(p)
    case other         => Left(other)
  },
  identity,
)

val paidAmount =
  Lens[Payment.Paid, Double](_.amount, (p, a) => p.copy(amount = a))

val bumpPaid =
  userIso
    .andThen(userOrders)
    .andThen(Traversal.each[List, Payment])
    .andThen(paidP)
    .andThen(paidAmount)
val input = UserTuple("Alice", List(
  Payment.Paid(10.0),
  Payment.Pending(20.0),
  Payment.Cancelled,
  Payment.Paid(30.0),
))
// input: UserTuple = UserTuple(
//   name = "Alice",
//   orders = List(Paid(10.0), Pending(20.0), Cancelled, Paid(30.0))
// )
bumpPaid.modify(_ + 5.0)(input)
// res31: UserTuple = UserTuple(
//   name = "Alice",
//   orders = List(Paid(15.0), Pending(20.0), Cancelled, Paid(35.0))
// )

Five hops, carriers Forgetful → Tuple2 → PowerSeries → Either → Tuple2. The cross-carrier .andThen does the morph lifting at each seam — the per-pair Monocle overload table becomes one Composer[F, G] lookup per hop. The full carrier graph is the composition lattice in the concepts page.

Source: composition demo drawn from Chapuis' hands-on intro https://jonaschapuis.com/2018/07/optics-a-hands-on-introduction-in-scala/ and Borjas' lunar-phase example https://tech.lfborjas.com/optics/.

Theme H — Effectful modification

Validate-in-place with modifyF

Bump age by 1, but fail with None if the input is negative. .modifyF[G] lifts an A => G[B] through any carrier that admits Functor[G]:

case class Visitor(name: String, age: Int)

val visitorAgeL =
  Lens[Visitor, Int](_.age, (v, a) => v.copy(age = a))
import cats.syntax.functor.*
import cats.instances.option.*

visitorAgeL.modifyF[Option](age =>
  if age >= 0 then Some(age + 1) else None
)(Visitor("Alice", 30))
// res32: Option[Visitor] = Some(Visitor(name = "Alice", age = 31))

visitorAgeL.modifyF[Option](age =>
  if age >= 0 then Some(age + 1) else None
)(Visitor("Alice", -1))
// res33: Option[Visitor] = None

.modifyA[G] is the Applicative[G] variant — reach for it when the chain has branching effects to combine (traversal + validation, for instance). Witherable-style filter-and-drop traversal is cross-referenced from Penner — Composable filters using Witherable optics; the carrier is deferred to a follow-up release.

Source: Monocle / Haskell lens classic traverseOf, generalised.

Batch-load nested IDs — O(N) → O(1) queries (cats-eo-unique)

Each node in a tree carries an ID that needs to resolve to a DB-backed record. Naive .modifyA fires one query per node; the optic spelling collects all IDs through a foldMap pass, issues one batched query, then .modify again to distribute results. 100×–300× without restructuring the domain types:

case class Node(id: Int, label: String)
case class Payload(id: Int, body: String)

// Stand-in for a DB-backed batch fetch.
def fetchAll(ids: List[Int]): Map[Int, Payload] =
  ids.map(i => i -> Payload(i, s"body-$i")).toMap

val eachLeaf = Traversal.each[List, Node]
val nodes = List(Node(1, "a"), Node(2, "b"), Node(3, "c"))
// nodes: List[Node] = List(
//   Node(id = 1, label = "a"),
//   Node(id = 2, label = "b"),
//   Node(id = 3, label = "c")
// )

// Pass 1: collect every ID into a single list via foldMap.
val allIds = eachLeaf.foldMap((n: Node) => List(n.id))(nodes)
// allIds: List[Int] = List(1, 2, 3)

// Pass 2: issue ONE query for the whole set.
val byId = fetchAll(allIds)
// byId: Map[Int, Payload] = Map(
//   1 -> Payload(id = 1, body = "body-1"),
//   2 -> Payload(id = 2, body = "body-2"),
//   3 -> Payload(id = 3, body = "body-3")
// )

// Pass 3: broadcast the resolved payloads back through the same
// traversal. The structure is preserved; only the labels shift.
eachLeaf.modify(n => n.copy(label = byId(n.id).body))(nodes)
// res34: List[Node] = List(
//   Node(id = 1, label = "body-1"),
//   Node(id = 2, label = "body-2"),
//   Node(id = 3, label = "body-3")
// )

The pattern generalises to trees, graphs, nested containers, anything with a Traverse. The two-pass idiom is what cats-eo's foldMap + modify pair already enables out of the box — no cats-eo-specific API to learn.

Source: Penner — Using traversals to batch database queries, https://chrispenner.ca/posts/traversals-for-batching.

Theme I — Observable failure (cats-eo-unique)

The next three recipes cover the observability story for the JSON cursor optics: partial-success walks, parse errors surfaced through the same chain, and how to classify the failures by case. The Ior failure-flow diagram has the full decision tree.

Partial-success array walk — Ior.Both

Across a mixed-failure array, produce the updated JSON and the per-element miss list. Ior.Both(chain, partialJson) is the observable shape — every element that did succeed is reflected in the payload, every refusal accumulates into the chain:

case class SimpleItem(name: String)
object SimpleItem:
  given Codec.AsObject[SimpleItem] = KindlingsCodecAsObject.derive

case class SimpleBasket(owner: String, items: Vector[SimpleItem])
object SimpleBasket:
  given Codec.AsObject[SimpleBasket] = KindlingsCodecAsObject.derive
val brokenArr = Json.arr(
  SimpleItem("x").asJson,
  Json.fromString("oops"),          // not a SimpleItem
  SimpleItem("z").asJson,
)
// brokenArr: Json = JArray(
//   Vector(
//     JObject(object[name -> "x"]),
//     JString("oops"),
//     JObject(object[name -> "z"])
//   )
// )
val brokenBasket =
  Json.obj("owner" -> Json.fromString("Alice"), "items" -> brokenArr)
// brokenBasket: Json = JObject(
//   object[owner -> "Alice",items -> [
//   {
//     "name" : "x"
//   },
//   "oops",
//   {
//     "name" : "z"
//   }
// ]]
// )

codecPrism[SimpleBasket]
  .items
  .each
  .name
  .modify(_.toUpperCase)(brokenBasket)
// res35: Ior[Chain[JsonFailure], Json] = Both(
//   a = Singleton(NotAnObject(Field("name"))),
//   b = JObject(
//     object[owner -> "Alice",items -> [
//   {
//     "name" : "X"
//   },
//   "oops",
//   {
//     "name" : "Z"
//   }
// ]]
//   )
// )

circe-optics silently drops per-element failures; cats-eo's JsonTraversal collects them so the caller can decide what to do. The getOrElse(input).noSpacesSortKeys escape hatch reproduces the pre-v0.2 silent shape byte-for-byte when you know you don't want the diagnostic.

Source: cats-eo internal.

Parse-error surface — Json | String input

When the input is a String, unparseable input surfaces as Ior.Left(Chain(JsonFailure.ParseFailed(_))) through the same chain as every other failure mode:

val upperName = codecPrism[SimpleBasket].items.each.name.modify(_.toUpperCase)
// Happy path: parsed, modified, Ior.Right.
upperName(
  """{"owner":"Alice","items":[{"name":"apple"}]}"""
).map(_.noSpacesSortKeys)
// res36: Ior[Chain[JsonFailure], String] = Right(
//   "{\"items\":[{\"name\":\"APPLE\"}],\"owner\":\"Alice\"}"
// )

// Parse failure: Ior.Left(Chain(JsonFailure.ParseFailed(_))).
upperName("not json at all")
// res37: Ior[Chain[JsonFailure], Json] = Left(
//   Singleton(
//     ParseFailed(
//       ParsingFailure(
//         message = "expected null got 'not js...' (line 1, column 1)",
//         underlying = ParseException(
//           msg = "expected null got 'not js...' (line 1, column 1)",
//           index = 0,
//           line = 1,
//           col = 1
//         )
//       )
//     )
//   )
// )

The widened (Json | String) => _ signature is a supertype of Json => _, so every pre-existing call site compiles unchanged. Parse cost is zero when the input is already a Json; one io.circe.parser.parse when it's a String.

Source: cats-eo internal.

Classify failures — why did the edit no-op?

Pattern-match on each JsonFailure case and route to distinct log / metric streams. The chain collects one entry per refusal point on the walk; the cases cover every way a cursor walk can skip:

import dev.constructive.eo.circe.JsonFailure

def route(chain: cats.data.Chain[JsonFailure]): List[String] =
  chain.toList.map {
    case JsonFailure.PathMissing(step)       => s"miss:    $step"
    case JsonFailure.NotAnObject(step)       => s"shape:   $step (not object)"
    case JsonFailure.NotAnArray(step)        => s"shape:   $step (not array)"
    case JsonFailure.IndexOutOfRange(step,n) => s"bounds:  $step (size=$n)"
    case JsonFailure.DecodeFailed(step, df)  => s"decode:  $step: ${df.message}"
    case JsonFailure.ParseFailed(pf)         => s"parse:   ${pf.message}"
  }
val stump2 = Json.obj("name" -> Json.fromString("Alice"))
// stump2: Json = JObject(object[name -> "Alice"])
userStreet.modify(_.toUpperCase)(stump2) match
  case Ior.Right(_)        => List("ok")
  case Ior.Both(chain, _)  => route(chain)
  case Ior.Left(chain)     => route(chain)
// res38: List[String] = List("miss:    Field(address)")

The Ior surface isn't a curiosity — it's production-grade observability. One vignette demonstrates how; per-case routing generalises to metrics, structured logs, or error surfaces at service boundaries.

Source: cats-eo internal.

Theme J — Streaming / Kafka

Kafka payload edit

Most Kafka consumers receive Array[Byte] payloads, want to modify a single field, and re-emit binary on the producer side. The AvroPrism triple-input surface accepts Array[Byte] directly, parses it through apache-avro's BinaryDecoder under the cached reader schema, and threads the modify back through without ever materialising the full case-class tree:

import dev.constructive.eo.avro as eoavro
import dev.constructive.eo.avro.AvroCodec
import hearth.kindlings.avroderivation.{AvroDecoder, AvroEncoder, AvroSchemaFor}
import java.io.ByteArrayOutputStream
import org.apache.avro.generic.{GenericDatumWriter, GenericRecord, IndexedRecord}
import org.apache.avro.io.EncoderFactory

case class OrderEvent(orderId: String, customer: String, total: Double)
object OrderEvent:
  given AvroEncoder[OrderEvent] = AvroEncoder.derived
  given AvroDecoder[OrderEvent] = AvroDecoder.derived
  given AvroSchemaFor[OrderEvent] = AvroSchemaFor.derived

// Stand-in for an inbound Kafka record: serialise an OrderEvent to
// binary under the same schema the prism caches.
val outSchema = summon[AvroCodec[OrderEvent]].schema
val sample = OrderEvent("ord-42", "alice", 99.99)
val sampleBytes: Array[Byte] =
  val rec = summon[AvroCodec[OrderEvent]].encode(sample).asInstanceOf[GenericRecord]
  val out = new ByteArrayOutputStream()
  val encoder = EncoderFactory.get().binaryEncoder(out, null)
  val writer = new GenericDatumWriter[GenericRecord](outSchema)
  writer.write(rec, encoder)
  encoder.flush()
  out.toByteArray

// The optic: walk into `customer` once at construction time,
// reuse on every inbound record. Disambiguate the Avro
// `codecPrism` from the circe one imported earlier in this page
// by qualifying through the `eoavro` alias.
val upperCustomer = eoavro.codecPrism[OrderEvent].customer

// Re-serialise the modified IndexedRecord back to binary for the
// producer side. In a real consumer this lives in your sink.
def toBinary(rec: IndexedRecord): Array[Byte] =
  val out = new ByteArrayOutputStream()
  val encoder = EncoderFactory.get().binaryEncoder(out, null)
  val writer = new GenericDatumWriter[GenericRecord](rec.getSchema)
  writer.write(rec.asInstanceOf[GenericRecord], encoder)
  encoder.flush()
  out.toByteArray
// Kafka hot path: bytes in → modify in place → bytes out.
val outBytes: Array[Byte] =
  toBinary(upperCustomer.modifyUnsafe(_.toUpperCase)(sampleBytes))
// outBytes: Array[Byte] = Array(
//   12,
//   111,
//   114,
//   100,
//   45,
//   52,
//   50,
//   10,
//   65,
//   76,
//   73,
//   67,
//   69,
//   -113,
//   -62,
//   -11,
//   40,
//   92,
//   -1,
//   88,
//   64
// )

// Round-trip witness — decode the output to confirm the customer
// field changed and the rest is preserved.
val outRec = upperCustomer
  .modifyUnsafe(_.toUpperCase)(sampleBytes)
  .asInstanceOf[GenericRecord]
// outRec: GenericRecord = {"orderId": "ord-42", "customer": "ALICE", "total": 99.99}
(outRec.get("customer").toString,
 outRec.get("orderId").toString,
 outRec.get("total"))
// res39: Tuple3[String, String, Object] = ("ALICE", "ord-42", 99.99)

modifyUnsafe is the silent-pass-through variant — bad bytes, missing fields, or decode mismatches leave the input bytes unmodified rather than allocating an Ior chain. That matches the Kafka consumer budget: at-least-once delivery already implies the consumer must be tolerant of malformed payloads at the offset commit boundary, and the per-record allocation cost of Ior is a tax on the happy path. When you DO want the diagnostic — for a dead-letter queue, say — the default .modify(...) call returns Ior[Chain[AvroFailure], IndexedRecord] for the exact same fixture; route on the Ior.Both / Ior.Left shape from there.

The cached reader schema is the load-bearing piece: a single codecPrism[OrderEvent] value pins the schema once, and the parser reuses it across millions of inbound records. For the schema-registry case where the reader schema arrives at runtime, use the explicit-schema overload — AvroPrism.codecPrism[OrderEvent](runtimeSchema) — to bypass the kindlings-derived schema entirely.

Source: cats-eo internal (AvroPrism's triple-input surface, Unit 10). Background framing on the streaming / Kafka use case lives in the Avro integration intro.

Further reading