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.
.collectMap[B](agg: F[A] => B)requiresFunctor[F]and is the carrier-wide default. Length-preserving viaFunctor[F].map. Matches v1Reflector[ZipList]andReflector[Const]exactly..collectList(agg: List[A] => B)isMultiFocus[List]-specific, producesList(agg(fa))— a one-element output regardless of input length. Matches v1Reflector[List]'s cartesian-singleton choice.
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 → Getter — Getter.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
- Concepts — the theory behind the unified Optic
trait and carriers; the
composition lattice
diagram maps out every
Composer[F, G]bridge. - Optics reference — the full per-family tour, introduced by the family taxonomy diagram.
- Generics — macro-derived
lens[S](...)andprism[S, A]. - Circe integration — cursor-backed JSON optics; failure flow for the Ior decision tree.
- Avro integration — cursor-backed Avro optics; failure flow for the schema-driven Ior decision tree.
- Migrating from Monocle — side-by-side translation guide.