cats-eo

Navigating structures

One branch of a sum; every node of a recursive tree; a sparse walk over a collection; several fields at once; and the same moves over JSON you never decode. The recipes ramp by shape, and the point throughout is that no step dead-ends: whatever you just focused, the next .andThen keeps going.

import dev.constructive.eo.optics.{Lens, Optic, Prism, Traversal}
import dev.constructive.eo.optics.Optic.*
import dev.constructive.eo.data.MultiFocus.given   // Functor / Foldable / Traverse for MultiFocus[PSVec] (post-fold)

Visit contingent fields

Why: you want to rewrite one variant of a sum type — the bound variable of a Var node in an AST, say — and leave every other case untouched, without a hand-written match that re-builds the misses. Both hops derive from the type itself: prism[S, A] matches the branch, lens[S](_.field) reaches inside it, and one .andThen chains them — no pattern match, no copy plumbing, no optic written by hand.

// The AST under edit — hosted at package level in
// dev.constructive.eo.docs (macro-derived optics need top-level
// targets; mdoc wraps every fence in an object):
//
//   enum Expr:
//     case Var(name: String)
//     case App(f: Expr, x: Expr)
//     case Lam(bind: String, body: Expr)
import dev.constructive.eo.docs.Expr
import dev.constructive.eo.generics.{lens, prism}

val varName = prism[Expr, Expr.Var].andThen(lens[Expr.Var](_.name))

// The focus arrives as the case's `(name: String)` NamedTuple —
// the selector covers Var's only field, so the derived optic is a
// full BijectionIso rather than a partial Lens.
val upperVar = varName.modify(v => (name = v.name.toUpperCase))
// Hit branch: the Var's name is uppercased.
upperVar(Expr.Var("x"))
// res0: Expr = Var("X")

// Miss branch: passes through, the Lam is untouched.
upperVar(Expr.Lam("y", Expr.Var("y")))
// res1: Expr = Lam(bind = "y", body = Var("y"))

The derived Prism rides the Either carrier and the derived field optic composes across the seam through the same .andThen as everything else on this page. When you need a Prism the macro can't express — a validating match, a normalising re-build — the hand-rolled Prism[S, A](sel, emb) constructor takes the match explicitly; see Generics → prism[S, A] for what the derivation covers (enums, sealed traits, union types) and the full story on the NamedTuple focus.

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/.

Visit across whole trees

varName edits exactly the node you hand it: Var("x") hits, Lam(...) misses — the nested Var("y") inside stays untouched, because a Prism looks at the top of the value, not into it. One more derivation closes the gap: plate[Expr] derives the Plated instance (which fields recurse, which are leaves), and everywhere turns it into a recursive optic that reaches every sub-term. The same varName optic then composes into it:

import dev.constructive.eo.optics.Plated
import dev.constructive.eo.generics.plate

given Plated[Expr] = plate[Expr]

// `everywhere` is `transform` in optic form: everywhere.andThen(d).modify(g)
// applies `d` at every node, bottom-up. Reuse `varName` from above.
val everyVarName = Plated.everywhere[Expr].andThen(varName)

val upperEveryVar = everyVarName.modify(v => (name = v.name.toUpperCase))
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")))

// varName alone: the top of `term` is an App, not a Var — a miss,
// nothing changes.
upperVar(term)
// res2: Expr = App(f = Var("f"), x = Lam(bind = "y", body = Var("y")))

// everywhere ∘ varName: 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.
upperEveryVar(term)
// res3: Expr = App(f = Var("F"), x = Lam(bind = "y", body = Var("Y")))

That pair of calls is the whole comparison: varName is the surgical single-node edit, everyVarName the same edit quantified over the tree — and the step between them is one derivation plus one .andThen, not a rewrite. 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.

Visit through arbitrary structure

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

// Result (Ok | Err) is hosted in dev.constructive.eo.docs like Expr
// above — enum macro targets need a package-level home under mdoc.
import dev.constructive.eo.docs.Result

val okP = prism[Result, Result.Ok]

val bumpOks =
  Traversal.each[ArraySeq, Result]
    .andThen(okP)
    // Result.Ok is single-field, where the full-cover lens macro returns
    // an Iso with a NamedTuple focus — hand-write the Int lens instead.
    .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))
)
// res4: 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/.

Isolate what you need

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.

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)
// res5: 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/.

Compute aggregations

A Lens sees one value; a Traversal visits a container's elements. Indexed addresses a fixed index space, while MultiFocus[F] supports container traversal and aggregation. The two recipes below show that distinction; see the MultiFocus reference.

Recipe A — Adjust a fixed-index configuration

Why: an environment-indexed configuration has a path for each coordinate. Read one, rewrite each with its index visible, or fill the whole space without enumerating or comparing indexes.

import cats.Representable
import cats.instances.function.given
import java.nio.file.Path
import dev.constructive.eo.optics.Indexed
import dev.constructive.eo.data.MultiFocus
import dev.constructive.eo.data.MultiFocus.given
import dev.constructive.eo.data.MultiFocus.{collectList, collectMap}

enum Env:
  case Dev, Prod

val readerRepr = summon[Representable.Aux[[a] =>> Env => a, Env]]
val configGlass = Indexed.representable[[a] =>> Env => a, Path](readerRepr)
val config: Env => Path =
  case Env.Prod => Path.of("/opt/app/")
  case Env.Dev  => Path.of("/dev/app/0292103")
configGlass.at(Env.Dev)(config)
// res6: Path = /dev/app/0292103
val adjustedConfig = configGlass.modify((env, path) =>
  if env == Env.Dev then path.resolve("logs") else path
)(config)
// adjustedConfig: Function1[Env, Path] = dev.constructive.eo.optics.Indexed$$Lambda/0x0000000011b12b88@77def28
(adjustedConfig(Env.Dev), adjustedConfig(Env.Prod))
// res7: Tuple2[Path, Path] = (/dev/app/0292103/logs, /opt/app)
val replacePath = Path.of("/")
// replacePath: Path = /
val replacedConfig = configGlass.replace(replacePath)(config)
// replacedConfig: Function1[Env, Path] = dev.constructive.eo.optics.Indexed$$Lambda/0x0000000011b13338@4b7b4c50
(replacedConfig(Env.Dev), replacedConfig(Env.Prod))
// res8: Tuple2[Path, Path] = (/, /)
require(configGlass.at(Env.Dev)(config) == Path.of("/dev/app/0292103"))
require(configGlass.at(Env.Prod)(config) == Path.of("/opt/app/"))
require(adjustedConfig(Env.Dev) == config(Env.Dev).resolve("logs"))
require(adjustedConfig(Env.Prod) == config(Env.Prod))
require(replacedConfig(Env.Dev) == replacePath)
require(replacedConfig(Env.Prod) == replacePath)

This representable constructor returns Indexed.Grate, the X = Unit specialization. Other glasses retain residual context. Nested andThen uses product indexes to address the full grid; Unit-context normalization and generic classical-family seams remain open. For effectful per-element rewrites, use container traversal.

For nested environment/channel settings, type-changing writes, and labelled sensor grids that retain per-row metadata, continue with Indexed: Grates and Glasses.

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

Recipe B — Summarise a batch, then broadcast or collapse

Why: a column of readings from a batch needs to become a report: each row pairing the original value with its distance from the batch average, plus the batch-level statistics — mean, variance, standard deviation — computed once. The same MultiFocus[F] optic covers every shape the report needs: broadcast a summary into every position, collapse the batch to a single footer value, rewrite every row relative to the batch in one expression, or fold out the statistical moments in one pass — you pick the flavour at the call site:

case class ReportRow(value: Double, distanceFromMean: Double)

val readings = List(12.0, 14.0, 11.0, 15.0)

val readingsMF = MultiFocus.apply[List, Double]
// (1) Baseline column — .collectMap broadcasts the batch mean back
//     into every position, keeping the shape.
readingsMF.collectMap[Double](xs => xs.sum / xs.size)(readings)
// res15: List[Double] = List(13.0, 13.0, 13.0, 13.0)

// (2) Report footer — .collectList collapses the batch to a single
//     summary value, whatever the input length.
readingsMF.collectList(xs => xs.sum / xs.size)(readings)
// res16: List[Double] = List(13.0)

// (3) The report rows, in one expression — .collectWith is the
//     algebraic-lens universal: the aggregate sees the whole batch
//     ONCE, computes the baseline, and returns the per-row rewrite.
//     The pApply factory makes the walk type-changing, so each
//     Double becomes a ReportRow pairing the original with its
//     distance from the average.
MultiFocus.pApply[List, Double, ReportRow].collectWith { xs =>
  val mean = xs.sum / xs.size
  v => ReportRow(v, v - mean)
}(readings)
// res17: List[ReportRow] = List(
//   ReportRow(value = 12.0, distanceFromMean = -1.0),
//   ReportRow(value = 14.0, distanceFromMean = 1.0),
//   ReportRow(value = 11.0, distanceFromMean = -2.0),
//   ReportRow(value = 15.0, distanceFromMean = 2.0)
// )

// (4) The batch-level statistics for the report footer: count, sum
//     and sum of squares in ONE foldMap pass via the tuple monoid,
//     then the moments fall out.
val (n, sum, sumSq) = readingsMF.foldMap(v => (1, v, v * v))(readings)
// n: Int = 4
// sum: Double = 52.0
// sumSq: Double = 686.0
val mean     = sum / n
// mean: Double = 13.0
val variance = sumSq / n - mean * mean   // population variance
// variance: Double = 2.5
val stdDev   = math.sqrt(variance)
// stdDev: Double = 1.5811388300841898

Which flavour?

Why distinct 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.

Edit JSON without decoding

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. Navigation doesn't stop at the case-class boundary: codecPrism[S] walks circe's Json directly, and only the focused leaf is materialised as A:

import dev.constructive.eo.circe.codecPrism
import io.circe.syntax.*

// The payload's case classes — hosted at package level in
// dev.constructive.eo.docs with kindlings-derived codecs (heavy
// derivation macros run in compiled sources, not mdoc fences):
//
//   case class UserAddress(street: String, zip: Int)
//   object UserAddress:
//     given Codec.AsObject[UserAddress] = KindlingsCodecAsObject.derived
//
//   case class SiteUser(name: String, address: UserAddress)   // ditto
import dev.constructive.eo.docs.{SiteUser, UserAddress}

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
// res18: 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.

The *Unsafe suffix opts out of the failure channel; the default modify returns Ior[Chain[JsonFailure], Json], so a silent no-op edit tells you which step refused — the circe integration's failure flow covers routing it.

Source: cats-eo internal (JsonPrism); related to circe-optics' root.* idiom https://circe.github.io/circe/optics.html.

…and 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:

// Same hosting pattern — kindlings-derived Codec.AsObject on both:
//
//   case class Item(name: String, price: Double)
//   case class Basket(owner: String, items: Vector[Item])
import dev.constructive.eo.docs.{Basket, Item}
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@160145e0

everyItemName.modifyUnsafe(_.toUpperCase)(basket.asJson).noSpacesSortKeys
// res19: 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 — the circe integration's failure flow shows how to route the chain.

Source: cats-eo internal (JsonTraversal).