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:

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