circe integration
The cats-eo-circe module adds two cursor-backed optics for
editing circe JSON without
paying the cost of a full decode / re-encode round-trip.
libraryDependencies += "dev.constructive" %% "cats-eo-circe" % "0.1"
Why this exists
The classical Scala JSON-edit pattern is:
json.as[Person] // decode
.map(p => p.copy(name = p.name.toUpperCase))
.map(_.asJson) // re-encode
That decodes every field of Person, allocates a fresh
instance, and re-encodes every field — even if only one
leaf is changing. For wide records the work is mostly wasted.
JsonPrism / JsonTraversal walk a flat path directly through
circe's JsonObject / array representation, modifying only
the focused leaf and rebuilding the parents on the way up.
The
JsonPrismBench
and
JsonTraversalBench
suites document a roughly 2× speedup at every depth and
every array size.
JsonPrism
import dev.constructive.eo.circe.codecPrism
import io.circe.Codec
import io.circe.syntax.*
import hearth.kindlings.circederivation.KindlingsCodecAsObject
case class Address(street: String, zip: Int)
object Address:
given Codec.AsObject[Address] = KindlingsCodecAsObject.derive
case class Person(name: String, age: Int, address: Address)
object Person:
given Codec.AsObject[Person] = KindlingsCodecAsObject.derive
Construct a Prism to the root type, then drill into fields.
The .address.street sugar is macro-powered — it compiles to
.field(_.address).field(_.street):
val alice = Person("Alice", 30, Address("Main St", 12345))
// alice: Person = Person(
// name = "Alice",
// age = 30,
// address = Address(street = "Main St", zip = 12345)
// )
val json = alice.asJson
// json: Json = JObject(
// object[name -> "Alice",age -> 30,address -> {
// "street" : "Main St",
// "zip" : 12345
// }]
// )
val streetP = codecPrism[Person].address.street
// streetP: JsonPrism[String] = dev.constructive.eo.circe.JsonPrism@728ee826
streetP.modifyUnsafe(_.toUpperCase)(json).noSpacesSortKeys
// res0: String = "{\"address\":{\"street\":\"MAIN ST\",\"zip\":12345},\"age\":30,\"name\":\"Alice\"}"
The default modify returns Ior[Chain[JsonFailure], Json] —
failures are surfaced rather than silently swallowed. The
*Unsafe variants preserve the pre-v0.2 silent behaviour.
Full coverage of both surfaces lives in the "Observable-by-default
failures" section of the v0.2 release notes.
Other operations (all the silent escape hatches):
streetP.getOptionUnsafe(json)
// res1: Option[String] = Some("Main St")
streetP.placeUnsafe("Broadway")(json).noSpacesSortKeys
// res2: String = "{\"address\":{\"street\":\"Broadway\",\"zip\":12345},\"age\":30,\"name\":\"Alice\"}"
streetP.transformUnsafe(_.mapString(_.reverse))(json).noSpacesSortKeys
// res3: String = "{\"address\":{\"street\":\"tS niaM\",\"zip\":12345},\"age\":30,\"name\":\"Alice\"}"
Forgiving semantics on the *Unsafe surface — missing paths leave
the Json unchanged:
import io.circe.Json
val stump = Json.obj("name" -> Json.fromString("Alice"))
// stump: Json = JObject(object[name -> "Alice"])
streetP.modifyUnsafe(_.toUpperCase)(stump).noSpacesSortKeys
// res4: String = "{\"name\":\"Alice\"}"
Array indexing
.at(i) drills into the i-th element of a JSON array:
case class Order(name: String)
object Order:
given Codec.AsObject[Order] = KindlingsCodecAsObject.derive
case class Basket(owner: String, items: Vector[Order])
object Basket:
given Codec.AsObject[Basket] = KindlingsCodecAsObject.derive
val basket = Basket("Alice", Vector(Order("X"), Order("Y"), Order("Z")))
// basket: Basket = Basket(
// owner = "Alice",
// items = Vector(Order("X"), Order("Y"), Order("Z"))
// )
val basketJson = basket.asJson
// basketJson: Json = JObject(
// object[owner -> "Alice",items -> [
// {
// "name" : "X"
// },
// {
// "name" : "Y"
// },
// {
// "name" : "Z"
// }
// ]]
// )
val secondName = codecPrism[Basket].items.at(1).name
// secondName: JsonPrism[String] = dev.constructive.eo.circe.JsonPrism@3346767c
secondName.modifyUnsafe(_.toUpperCase)(basketJson).noSpacesSortKeys
// res5: String = "{\"items\":[{\"name\":\"X\"},{\"name\":\"Y\"},{\"name\":\"Z\"}],\"owner\":\"Alice\"}"
Out-of-range / negative / non-array positions pass through unchanged.
JsonTraversal (.each)
.each splits the path at the current array focus and returns
a JsonTraversal that walks every element. Further .field /
.at / selectable-sugar calls on the traversal extend the
per-element suffix:
val everyName = codecPrism[Basket].items.each.name
// everyName: JsonTraversal[String] = dev.constructive.eo.circe.JsonTraversal@7da594f7
everyName.modifyUnsafe(_.toUpperCase)(basketJson).noSpacesSortKeys
// res6: String = "{\"items\":[{\"name\":\"X\"},{\"name\":\"Y\"},{\"name\":\"Z\"}],\"owner\":\"Alice\"}"
everyName.getAllUnsafe(basketJson)
// res7: Vector[String] = Vector("X", "Y", "Z")
Empty arrays and missing paths leave the Json unchanged.
Multi-field focus — .fields(_.a, _.b)
.fields(selector1, selector2, ...) focuses a bundle of named
case-class fields as a Scala 3 NamedTuple. Selectors arrive in
selector-order; the NamedTuple type reflects that. Arity must be
≥ 2 — use .field(_.x) for a single-field focus.
type NameAge = NamedTuple.NamedTuple[("name", "age"), (String, Int)]
given Codec.AsObject[NameAge] = KindlingsCodecAsObject.derive
val nameAge = codecPrism[Person].fields(_.name, _.age)
// nameAge: JsonPrism[NamedTuple[*:["name", *:["age", EmptyTuple]], *:[String, *:[Int, EmptyTuple]]]] = dev.constructive.eo.circe.JsonPrism@62f1f1d4
nameAge
.modifyUnsafe(nt => (name = nt.name.toUpperCase, age = nt.age + 1))(json)
.noSpacesSortKeys
// res8: String = "{\"address\":{\"street\":\"Main St\",\"zip\":12345},\"age\":31,\"name\":\"ALICE\"}"
Full-cover selection — spanning every field of Person — still
returns a JsonFieldsPrism, not an Iso. JSON decode can
always fail (the input may not even be a JSON object) so totality
isn't witnessable, and an Iso would misleadingly advertise a
guarantee we cannot provide.
type Full = NamedTuple.NamedTuple[("name", "age", "address"), (String, Int, Address)]
given Codec.AsObject[Full] = KindlingsCodecAsObject.derive
val fullL = codecPrism[Person].fields(_.name, _.age, _.address)
// fullL: JsonPrism[NamedTuple[*:["name", *:["age", *:["address", EmptyTuple]]], *:[String, *:[Int, *:[Address, EmptyTuple]]]]] = dev.constructive.eo.circe.JsonPrism@2e515a59
Per-element multi-field focus via .each.fields focuses a
NamedTuple on every element of an array:
case class Item(name: String, price: Double, qty: Int)
object Item:
given Codec.AsObject[Item] = KindlingsCodecAsObject.derive
case class MultiBasket(owner: String, items: Vector[Item])
object MultiBasket:
given Codec.AsObject[MultiBasket] = KindlingsCodecAsObject.derive
type NamePrice = NamedTuple.NamedTuple[("name", "price"), (String, Double)]
given Codec.AsObject[NamePrice] = KindlingsCodecAsObject.derive
val mbJson = MultiBasket(
"Alice",
Vector(Item("x", 1.0, 1), Item("y", 2.0, 2)),
).asJson
// mbJson: Json = JObject(
// object[owner -> "Alice",items -> [
// {
// "name" : "x",
// "price" : 1.0,
// "qty" : 1
// },
// {
// "name" : "y",
// "price" : 2.0,
// "qty" : 2
// }
// ]]
// )
codecPrism[MultiBasket]
.items
.each
.fields(_.name, _.price)
.modifyUnsafe(nt => (name = nt.name.toUpperCase, price = nt.price * 2))(mbJson)
.noSpacesSortKeys
// res9: String = "{\"items\":[{\"name\":\"X\",\"price\":2.0,\"qty\":1},{\"name\":\"Y\",\"price\":4.0,\"qty\":2}],\"owner\":\"Alice\"}"
Reading diagnostics from the default Ior surface
The default modify / transform / place / transfer / get
methods on JsonPrism, JsonFieldsPrism, JsonTraversal, and
JsonFieldsTraversal all return Ior[Chain[JsonFailure], Json]
(or , A] / , Vector[A]] for the reads). Three observable
shapes:
Ior.Right(json)— clean success.Ior.Both(chain, json)— partial success. Thejsonreflects every update that did succeed; thechainlists every skip.Ior.Left(chain)— no result producible. Typical forgetmisses and for traversal-prefix misses where there's nothing to iterate.
Failure flow
The diagram below traces the path a Json | String input takes
through a default-surface read/modify, showing which
JsonFailure case lands in the chain at each refusal point and
which Ior shape the caller observes.
flowchart TD
input["Json | String input"] --> parsed{parses?}
parsed -- "no (String only)" --> parseFail["ParseFailed"]
parseFail --> iorLeft(["Ior.Left(chain)"])
parsed -- yes --> walk{prefix walk}
walk -- "field on non-object" --> notObj["NotAnObject"]
walk -- "field absent" --> pathMissing["PathMissing"]
walk -- "at on non-array" --> notArr["NotAnArray"]
walk -- "index out of range" --> idxOOR["IndexOutOfRange"]
walk -- "decoder refused" --> decodeFail["DecodeFailed"]
walk -- "clean path" --> travStep{traversal?}
notObj --> opShape1{operation}
pathMissing --> opShape1
notArr --> opShape1
idxOOR --> opShape1
decodeFail --> opShape1
opShape1 -- "get / prefix-miss traversal" --> iorLeft
opShape1 -- "modify / place / transform" --> iorBoth(["Ior.Both(chain, inputJson)"])
travStep -- "no (scalar)" --> iorRight(["Ior.Right(json)"])
travStep -- "yes" --> perElt{per-element walk}
perElt -- "every element OK" --> iorRight
perElt -- "some elements skip" --> iorBothPartial(["Ior.Both(chain, partialJson)"])
perElt -- "empty array" --> iorRight
- Prefix-walk failures (the
NotAnObject/PathMissing/NotAnArray/IndexOutOfRange/DecodeFailedbranches) surface asIor.Lefton read operations andIor.Both(chain, inputJson)on modify operations — the unchanged input Json rides along so the caller can keep walking. - Per-element traversal skips (the
.eachpath) accumulate oneJsonFailureper refused element and land inIor.Bothtogether with the partially-updated Json — every element that did succeed is reflected in the payload. ParseFailedis the only case that can fire on string inputs; when the caller hands in aJsondirectly, that branch is unreachable.
import cats.data.Ior
import dev.constructive.eo.circe.{JsonFailure, PathStep}
val stumpJson = Json.obj("name" -> Json.fromString("Alice"))
// stumpJson: Json = JObject(object[name -> "Alice"])
// default modify returns Ior.Both on a failure — the Json is
// unchanged, the chain documents the miss
streetP.modify(_.toUpperCase)(stumpJson)
// res10: Ior[Chain[JsonFailure], Json] = Both(
// a = Singleton(PathMissing(Field("address"))),
// b = JObject(object[name -> "Alice"])
// )
Each JsonFailure case carries the PathStep at which the walk
refused, plus (for DecodeFailed) the underlying circe
DecodingFailure:
val miss: JsonFailure = JsonFailure.PathMissing(PathStep.Field("street"))
// miss: JsonFailure = PathMissing(Field("street"))
miss.message
// res11: String = "path missing at Field(street)"
Traversal-side accumulation collects one JsonFailure per
skipped element — on a mixed-failure array, the Ior.Both Json
reflects the successful updates and the chain lists every element
that was left unchanged:
val brokenArr = Json.arr(
Order("x").asJson,
Json.fromString("oops"), // not an Order
Order("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[Basket]
.items
.each
.name
.modify(_.toUpperCase)(brokenBasket)
// res12: Ior[Chain[JsonFailure], Json] = Both(
// a = Singleton(NotAnObject(Field("name"))),
// b = JObject(
// object[owner -> "Alice",items -> [
// {
// "name" : "X"
// },
// "oops",
// {
// "name" : "Z"
// }
// ]]
// )
// )
String input — parse on the fly
Every edit and read method on JsonPrism / JsonFieldsPrism /
JsonTraversal / JsonFieldsTraversal accepts Json | String as
the source. When you hand in a String, the library parses it
first and surfaces parse errors through the same JsonFailure
accumulator as every other failure mode.
val incoming: String =
"""{"name":"Alice","age":30,"address":{"street":"Main St","zip":12345}}"""
val upperName = codecPrism[Person].field(_.name).modify(_.toUpperCase)
// Happy path: parsed, modified, Ior.Right.
upperName(incoming).map(_.noSpacesSortKeys)
// res13: Ior[Chain[JsonFailure], String] = Right(
// "{\"address\":{\"street\":\"Main St\",\"zip\":12345},\"age\":30,\"name\":\"ALICE\"}"
// )
// Parse failure: Ior.Left(Chain(JsonFailure.ParseFailed(_))).
upperName("not json at all")
// res14: 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
// )
// )
// )
// )
// )
Handing in a Json directly still works unchanged — the widened
(Json | String) => _ signature is a supertype of the old Json =>
_, so every pre-existing call site compiles without change. The
parse cost is zero when the input is already a Json; it's one
io.circe.parser.parse invocation when it's a String.
On the *Unsafe surface, unparseable strings fall back to
Json.Null — there's no meaningful "input unchanged" semantic for
text that isn't JSON, and the whole point of *Unsafe is to drop
failure detail. Callers who need parse diagnostics stay on the
default Ior-bearing surface.
Ignoring failures (the *Unsafe escape hatch)
For callers who have measured and know they don't want the Ior
allocation, every default method has a sibling *Unsafe variant
that preserves the pre-v0.2 silent-forgiving behaviour
byte-for-byte:
// Pre-v0.2 shape: modifyUnsafe, silent pass-through on miss.
streetP.modifyUnsafe(_.toUpperCase)(stumpJson).noSpacesSortKeys
// res15: String = "{\"name\":\"Alice\"}"
// Equivalent via the default surface:
streetP.modify(_.toUpperCase)(stumpJson).getOrElse(stumpJson).noSpacesSortKeys
// res16: String = "{\"name\":\"Alice\"}"
Both spellings produce the same Json. The first pays nothing for
diagnostics; the second gives an observable Ior at the price of
one allocation.
Migration notes (v0.2 rename)
v0.2 renames the silent methods to *Unsafe and repurposes the
clean name for the Ior-bearing surface. The mechanical replacement
is: swap the method name for its *Unsafe sibling if you want
the pre-v0.2 behaviour preserved exactly.
| Class | v0.1 (silent) | v0.2 default (Ior-bearing) | v0.2 *Unsafe (silent) |
|---|---|---|---|
JsonPrism[A] |
modify(f) |
modify(f): Json => Ior[Chain[JsonFailure], Json] |
modifyUnsafe(f) |
JsonPrism[A] |
transform(f) |
transform(f): Json => Ior[...] |
transformUnsafe(f) |
JsonPrism[A] |
place(a) |
place(a): Json => Ior[...] |
placeUnsafe(a) |
JsonPrism[A] |
transfer(f) |
transfer(f): Json => C => Ior[...] |
transferUnsafe(f) |
JsonPrism[A] |
getOption |
get(j): Ior[..., A] |
getOptionUnsafe |
JsonFieldsPrism[A] |
(new) | same five | same five |
JsonTraversal[A] |
modify(f) |
modify(f): Json => Ior[...] |
modifyUnsafe(f) |
JsonTraversal[A] |
transform(f) |
transform(f): Json => Ior[...] |
transformUnsafe(f) |
JsonTraversal[A] |
getAll |
getAll(j): Ior[..., Vector[A]] |
getAllUnsafe |
JsonTraversal[A] |
(new in v0.2) | place(a) / transfer(f) (Ior-bearing) |
placeUnsafe / transferUnsafe |
JsonFieldsTraversal[A] |
(new) | same five | same five |
The *Unsafe bodies are byte-identical to the pre-v0.2 silent
shape — no behaviour change, just the rename. The default Ior
surface is a new option: reach for it when you want to see the
path-level diagnostic.
When to reach for which
| Task | Use |
|---|---|
| Edit one leaf deep in a JSON tree | JsonPrism via .address.street sugar |
Edit element i of a JSON array |
codecPrism[…].items.at(i).… |
| Edit every element of a JSON array | codecPrism[…].items.each.… + modify |
| Read every element's focus | codecPrism[…].items.each.… + getAll |
| Edit multiple fields atomically | codecPrism[…].fields(_.a, _.b).modify(...) |
| Observe why a modify was a silent no-op | default Ior-bearing .modify(...) — inspect the Ior.Both / Ior.Left chain |
| Edit the whole root record (and you have a Codec) | codecPrism[Person].modifyUnsafe(f) |
For the full failure-mode matrix (missing paths, non-array focuses, empty collections, out-of-range indices), see the behaviour spec.