Cookbook
Runnable patterns for the questions that come up most often. Each
recipe opens with the problem it solves, 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.
These are worked examples, not a tutorial — for the optic families themselves see the Optics reference. Recipes are grouped by theme, moving from the everyday 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.MultiFocus.given // Functor / Foldable / Traverse for MultiFocus[PSVec] (post-fold)
Theme A — Product editing
Virtual-field Iso — Celsius ↔ Fahrenheit facade
Why: your type stores Celsius, but half your callers think in
Fahrenheit. Expose a fahrenheit handle that reads and writes as
if the field were really there — one representation, no drift, and
the unit choice stays an implementation detail callers never see.
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))
// res0: Double = 212.0
fahrenheit.reverseGet(32.0)
// res1: Temperature = Temperature(0.0)
fahrenheit.modify(_ + 9.0)(Temperature(0.0))
// res2: 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.
Total inventory value — multi-field focus into a fold (cats-eo-unique)
Why: you have a basket of line items and want one number —
total value — without writing a fold by hand or threading two
fields through a loop. The lens[S](_.a, _.b) macro focuses both
quantity and price as a single Scala 3 NamedTuple, so the
multiply-and-sum drops straight into foldMap. No Monocle
analogue for the multi-field focus.
import cats.instances.list.given // Traverse[List] for .each
import cats.instances.double.given // Monoid[Double] for .foldMap
import dev.constructive.eo.generics.lens
case class OrderItem(sku: String, quantity: Int, price: Double)
// Focus both pricing fields at once, then walk every item.
val lineValue =
Traversal.each[List, OrderItem]
.andThen(lens[OrderItem](_.quantity, _.price))
val inventory = List(
OrderItem("apple", 3, 9.99),
OrderItem("pear", 10, 2.50),
OrderItem("lobster", 2, 45.00),
)
// inventory: List[OrderItem] = List(
// OrderItem(sku = "apple", quantity = 3, price = 9.99),
// OrderItem(sku = "pear", quantity = 10, price = 2.5),
// OrderItem(sku = "lobster", quantity = 2, price = 45.0)
// )
// One pass: see each item's (quantity, price) NamedTuple, multiply,
// and let the Double monoid sum the line values into a grand total.
lineValue.foldMap(nt => nt.quantity * nt.price)(inventory)
// res3: Double = 144.97
The focus arrives in selector order, so nt.quantity and
nt.price are named — the lambda reads like the business rule it
encodes. The same lineValue optic also writes (.modify to
re-price every line); the fold is just the read-side escape. See
Generics → Multi-field Lens for the full treatment
of the NamedTuple focus.
Source: cats-eo internal; fold framing from Penner — Optics By Example ch. 7, https://leanpub.com/optics-by-example/.
Theme B — Sum-branch access
Prism + Lens — branch into a case, then edit inside
Why: you want to rewrite one variant of a sum type — rename
the bound variable of every Var node in an AST, say — and leave
every other case untouched, without a hand-written match that
re-builds the misses. The Prism selects the branch, the Lens edits
inside it, and misses pass through for free.
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"))
// res4: Expr = Var("X")
// Miss branch: passes through, the Lam is untouched.
varName.modify(_.toUpperCase)(Expr.Lam("y", Expr.Var("y")))
// res5: 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/.
…and across the whole tree — Plated + everywhere (cats-eo-unique)
The Prism above edits one Var. Give Expr a Plated and the
same varName optic composes into everywhere — a recursive
optic that reaches every sub-term — so one .andThen chain plus
.modify uppercases every variable at every depth:
import dev.constructive.eo.optics.Plated
// `plate[Expr]` from eo-generics derives this; spelled out by hand so
// the page stays macro-free. It names each case's immediate Expr children.
given Plated[Expr] = Plated.fromChildren(
{
case Expr.App(f, x) => List(f, x)
case Expr.Lam(_, body) => List(body)
case Expr.Var(_) => Nil
},
{
case (Expr.App(_, _), f :: x :: Nil) => Expr.App(f, x)
case (Expr.Lam(b, _), body :: Nil) => Expr.Lam(b, body)
case (leaf, _) => leaf
},
)
// `everywhere` is `transform` in optic form: everywhere.andThen(d).modify(g)
// applies `d` at every node, bottom-up. Reuse `varName` (Prism → Lens) from above.
val everyVarName = Plated.everywhere[Expr].andThen(varName)
val term = Expr.App(Expr.Var("f"), Expr.Lam("y", Expr.Var("y")))
// term: Expr = App(f = Var("f"), x = Lam(bind = "y", body = Var("y")))
// Every variable, at every depth: f -> F and the nested y -> Y. The Lam
// binder "y" (a String, not a Var node) is left alone.
everyVarName.modify(_.toUpperCase)(term)
// res6: Expr = App(f = Var("F"), x = Lam(bind = "y", body = Var("Y")))
everywhere composes like any other optic — .andThen a Prism to
pick a case, a Lens to reach a field — and the .modify runs at every
node, bottom-up and stack-safe to any depth (a million-node tree, or a
100k-deep degenerate spine, won't overflow). When the rewrite is easier
as a plain per-node function, Plated.transform(f) is the same engine;
Plated.universe / children are the read side (every sub-term /
immediate children); rewrite repeats an Expr => Option[Expr] rule
to a fixpoint. The derivation follows the exact self-type rule —
only Expr-typed fields are recursion points, so Lam's
bind: String stays a leaf. See Generics → plate[S].
Source: Mitchell & Runciman — Uniform Boilerplate and List
Processing (Uniplate),
https://ndmitchell.com/downloads/paper-uniform_boilerplate_and_list_processing-30_sep_2007.pdf;
Haskell lens Control.Lens.Plated,
https://hackage.haskell.org/package/lens/docs/Control-Lens-Plated.html.
Theme C — Collection walks
each past the traversal — drill, then keep drilling
Why: flip the isMobile flag on every phone a subscriber owns.
The point isn't the map — it's that each keeps composing: walk
into the list, traverse it, and drill into a field of each
element, all in one optic. A plain .map would make you re-assemble
the surrounding Subscriber by hand on the way back out.
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"),
)))
// res7: Subscriber = Subscriber(
// List(
// Dial(isMobile = true, number = "555-0001"),
// Dial(isMobile = false, number = "555-0002")
// )
// )
The cost tradeoff is documented in
Optics → PowerSeries 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)
Why: you have a list of results, some Ok and some Err, and
want to bump only the successes — leaving the failures exactly
where they are. Walking the container and matching the branch in
one pass is the "sparse traversal" that's genuinely annoying to
hand-roll; here it's 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))
)
// res8: 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
Why: you need to change one field deep inside a JSON payload and pass the rest through untouched — no full decode into a case-class tree, no re-encode, no decoder for the siblings you never read. One vignette in three acts: edit a deep leaf, edit every element of a nested array, then see 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
// res9: String = "{\"address\":{\"street\":\"MAIN ST\",\"zip\":12345},\"name\":\"Alice\"}"
The
OrderCirceBench
suite shows this edit is flat in document size — ~1.3 µs whether the record
is small or large — while the decode / .copy / re-encode path scales with the
whole payload, so the gap grows from ~3× on a tiny record to ~160× on a large
one. 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@3186a6b1
everyItemName.modifyUnsafe(_.toUpperCase)(basket.asJson).noSpacesSortKeys
// res10: 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)
// res11: 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 H 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
Why: the jq one-liner you'd reach for at the shell —
.items[] | select(.price > 100) | .name |= ascii_upcase,
"uppercase the name of every item over $100" — but in typed Scala,
over a Json you never fully decode. The optic reaches through the
multi-field focus (a 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
// res12: 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
Why: rename every leaf of your own recursive tree type —
not a container the library knows about. cats-eo ships no tree
carrier and doesn't need one: if your type has a cats.Traverse,
each picks it up and walks it. Bring your own instance:
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)
// res13: 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 — Many focuses at once: rewrite, aggregate, broadcast
A Lens sees one value; a Traversal sees many but only lets you
map over them. MultiFocus[F] is the optic for the jobs in between:
rewrite every slot of a fixed-shape record with one function,
fold the focused values into a summary and scatter it back across
them, or walk a nested collection and keep composing past it. The
three recipes below are the prototypical jobs; the MultiFocus
reference carries the design story (it unifies five
separate optics from v1 into this one carrier).
Recipe A — Adjust every channel of a colour at once
Why: you have a fixed-shape record of same-typed fields — the
R, G, B of a colour, the x/y/z of a vector, the left/right of a
stereo frame — and you want to hit all of them with one function.
There's no collection to traverse and no reason to write three
.copy calls: focus the whole tuple and rewrite every slot
uniformly, or fill them all with a constant.
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 function runs at
// every slot.
rgbMF.modify(c => (c * 1.4).min(1.0))(violet)
// res14: 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)
// res15: Tuple3[Double, Double, Double] = (0.0, 0.0, 0.0)
.modify runs the same function at every slot; .replace(b) fills
every slot with b. This is the Grate shape of MultiFocus
(details), and it works over any
fixed-arity homogeneous structure, not just tuples. One caveat: the
tuple/Grate shape is map-only — when you need an effectful
per-slot rewrite (.modifyA[G]), reach for a collection-backed
MultiFocus.apply[List, A] instead.
Source: Penner — Grate: yet another optic, https://chrispenner.ca/posts/grate.
Recipe B — Summarise a batch, then broadcast or collapse
Why: you have a column of numbers from a batch — sensor
readings, line-item amounts, weights — and you need a summary in a
particular shape. Sometimes you want it written back into every
position (so each row carries the batch total as the denominator
for a "share of total", or the mean as a baseline); sometimes you
want the batch collapsed to a single value. The same MultiFocus[F]
optic does both — you pick the flavour at the call site:
import cats.data.ZipList
val zipMF = MultiFocus.apply[ZipList, Double]
val intListMF = MultiFocus.apply[List, Int]
// (1) .collectMap broadcasts one summary back to every position —
// here the batch mean, so every slot ends up holding the average.
// (.value just unwraps the ZipList so the result prints legibly.)
zipMF.collectMap[Double](zl => zl.value.sum / zl.value.size.toDouble)(
ZipList(List(1.0, 2.0, 3.0, 4.0))
).value
// res16: List[Double] = List(2.5, 2.5, 2.5, 2.5)
// (2) .collectList collapses the whole batch to a single value —
// the total as a one-element result, whatever the input length.
intListMF.collectList(_.sum)(List(1, 2, 3, 4))
// res17: List[Int] = List(10)
// (3) Same optic, .collectMap flavour: the grand total stamped onto
// every position — the denominator for a later "share of total".
intListMF.collectMap[Int](_.sum)(List(1, 2, 3, 4))
// res18: List[Int] = List(10, 10, 10, 10)
Which flavour?
.collectMap[B](agg: F[A] => B)keeps the shape — the summary lands in every position. Reach for it when a downstream step needs the aggregate alongside the originals (share-of-total, a normalisation baseline)..collectList(agg: List[A] => B)produces a single-element result regardless of input length. Reach for it when you just want the summary.
Why two flavours, rather than one derived automatically? The MultiFocus reference has the answer.
Sources: Penner — Kaleidoscopes: lenses that never die, https://chrispenner.ca/posts/kaleidoscopes; Penner — Algebraic lenses, https://chrispenner.ca/posts/algebraic.
Recipe C — Bulk-edit a nested collection, then read it back
Why: the everyday "update all the line items" edit — rename
every order in a cart, bump every price, flag every overdue
invoice. The focus lives two hops deep: into the record, then
across the list it holds. The payoff is that the traversal doesn't
dead-end — the same optic keeps composing past .each to reach
the exact field you want, and folds back out, so one value gives
you both the write (rename every order) and the read (pull every
name for a report).
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, one .andThen each, no manual .morph:
// Cart → its orders (a List) → each order → that order's name
val everyOrderName =
cartOrdersL
.andThen(Traversal.each[List, Order])
.andThen(orderNameL) // the downstream hop past .each that pays off
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)
// res19: 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)
// res20: List[String] = List("apple", "pear", "lobster")
The .andThen(orderNameL) after the traversal is the part that
earns its keep: because .each doesn't dead-end, you compose
toward the field you actually want instead of stopping at the list
and re-drilling by hand — and the same value still .foldMaps for
the read side. At scale it stays cheap, within a few percent of a
hand-tuned traversal up to ~1k elements; the
PowerSeries benchmarks
have the curve.
Source: cats-eo internal; performance discussion in Optics → PowerSeries and the benchmarks.
Theme F — Composition
Three-family ladder: Iso → Lens → Traversal → Prism → Lens
Why: real edits cross optic families — an Iso here, a Lens, a
Traversal over a list, a Prism into one variant, another Lens
inside it. In Monocle each adjacent pair needs the right compose*
overload; in cats-eo one .andThen bridges every seam with no
manual .morph. Here: from a UserTuple, pull out the
orders: List[Payment], narrow to the Paid variant, and bump
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)
// res21: UserTuple = UserTuple(
// name = "Alice",
// orders = List(Paid(15.0), Pending(20.0), Cancelled, Paid(35.0))
// )
Five hops, carriers Direct → 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 G — Effectful modification
Validate-in-place with modifyF
Why: the update can fail. Bump age by 1, but reject a
negative input with None — and keep the validation and the write
as one expression instead of a get / check / set dance. .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))
// res22: Option[Visitor] = Some(Visitor(name = "Alice", age = 31))
visitorAgeL.modifyF[Option](age =>
if age >= 0 then Some(age + 1) else None
)(Visitor("Alice", -1))
// res23: 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)
Why: every node in a structure carries an ID you need to
resolve against a database, and the naive .modifyA fires one
query per node — the classic N+1. Use the same traversal twice:
foldMap collects every ID in one pass, you issue a single batched
query, then .modify distributes the results back. 100×–300×
fewer queries with no change to 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)
// res24: 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.
Persist-and-stamp — return the stored object with its DB id
Why: a PUT (or POST) handler receives a draft entity on the
wire, persists it, and must return the same entity enriched with
the database-assigned id. The id only exists after the effectful
store runs, so this is the classic "set one field to the result of
an effect" shape — and a derived id-lens makes the stamp a one-liner.
With circe carrying the value across the wire on both ends, the
whole handler is a short pipeline: decode → store → stamp →
encode.
import io.circe.{Codec, Json}
import io.circe.syntax.*
import io.circe.parser.decode
import hearth.kindlings.circederivation.KindlingsCodecAsObject
import dev.constructive.eo.generics.lens
// Stand-in for your effect type (cats-effect IO, ZIO, Future…).
final class IO[A](val unsafeRun: () => A):
def map[B](f: A => B): IO[B] = IO(f(unsafeRun()))
def flatMap[B](f: A => IO[B]): IO[B] = IO(f(unsafeRun()).unsafeRun())
object IO:
def apply[A](a: => A): IO[A] = new IO(() => a)
final case class BalanceSheet(id: Long, owner: String, total: Double)
object BalanceSheet:
given Codec.AsObject[BalanceSheet] = KindlingsCodecAsObject.derive
// One derived lens onto the id — no hand-written getter/setter, and
// it works even though `id` shares the case class with two other fields.
val sheetId = lens[BalanceSheet](_.id)
// The effectful store: inserts the row, hands back the generated id.
def save(sheet: BalanceSheet): IO[Long] = IO {
val _ = sheet // pretend: INSERT … RETURNING id
42L
}
// Persist, then stamp the returned id back onto the object with the
// lens — `save(...).map(sheetId.replace(_)(sheet))`.
def store(sheet: BalanceSheet): IO[BalanceSheet] =
save(sheet).map(sheetId.replace(_)(sheet))
// The whole PUT handler: request bytes in, response bytes out.
def handlePut(body: String): IO[String] =
decode[BalanceSheet](body) match
case Right(draft) => store(draft).map(_.asJson.noSpaces)
case Left(err) => IO(Json.obj("error" -> err.getMessage.asJson).noSpaces)
// Incoming PUT body — `id` is a placeholder the store overwrites.
val request = """{"id":0,"owner":"Acme Corp","total":1234.5}"""
// request: String = "{\"id\":0,\"owner\":\"Acme Corp\",\"total\":1234.5}"
handlePut(request).unsafeRun()
// res25: String = "{\"id\":42,\"owner\":\"Acme Corp\",\"total\":1234.5}"
Every step earns its place: decode is the wire → domain hop
(circe), store is your effect, the stamp is the derived lens
standing in for a hand-written sheet.copy(id = newId), and
asJson is the domain → wire hop back out. The lens is the only
optic, and it stays a reusable value — compose it further
(orderLens.andThen(sheetId)) the moment the entity nests inside a
larger request. In production you'd reach for an opaque DbId
rather than a bare Long, and likely a separate Draft type
without the id field; the shape of the pipeline doesn't change.
Source: the lens-stamps-the-effect-result pattern, https://gist.github.com/kryptt/af0a626849f0e3f5b16fcd161a5545e4; see also Generics → Composing into pipelines.
Theme H — 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
Why: some elements of the array decode, some don't, and you
want both outcomes — the JSON updated where it could be, and a
list of which elements were skipped — not a silent drop.
Ior.Both(chain, partialJson) is exactly that shape: every
success lands 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)
// res26: 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
Why: the bytes on the wire might not be JSON at all. Feed the
optic a raw String and a parse failure comes back as
Ior.Left(Chain(JsonFailure.ParseFailed(_))) — through the same
channel as every other failure, so you handle malformed input and
a missing field with one match:
val upperName = codecPrism[SimpleBasket].items.each.name.modify(_.toUpperCase)
// Happy path: parsed, modified, Ior.Right.
upperName(
"""{"owner":"Alice","items":[{"name":"apple"}]}"""
).map(_.noSpacesSortKeys)
// res27: Ior[Chain[JsonFailure], String] = Right(
// "{\"items\":[{\"name\":\"APPLE\"}],\"owner\":\"Alice\"}"
// )
// Parse failure: Ior.Left(Chain(JsonFailure.ParseFailed(_))).
upperName("not json at all")
// res28: 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?
Why: the edit did nothing and you need to know which step
refused — a missing path? the wrong shape? a decode failure? — so
each cause can go to its own log or metric. Pattern-match the
JsonFailure cases; the chain holds one entry per refusal point on
the walk, covering 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)
// res29: 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 I — Streaming / Kafka
Kafka payload edit
Why: a Kafka consumer gets an Array[Byte] payload, needs to
change one field, and re-emits binary on the producer side — on a
hot path where decoding the whole record into a case-class tree per
message is pure overhead. The AvroPrism triple-input surface
takes the Array[Byte] directly, parses it through apache-avro's
BinaryDecoder under a cached reader schema, and threads the modify
back through without ever materialising the full 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"))
// res30: 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.