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 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] = eo.circe.JsonPrism@2592b648

streetP.modify(_.toUpperCase)(json).noSpacesSortKeys
// res0: String = "{\"address\":{\"street\":\"MAIN ST\",\"zip\":12345},\"age\":30,\"name\":\"Alice\"}"

Other operations:

streetP.getOption(json)
// res1: Option[String] = Some("Main St")
streetP.place("Broadway")(json).noSpacesSortKeys
// res2: String = "{\"address\":{\"street\":\"Broadway\",\"zip\":12345},\"age\":30,\"name\":\"Alice\"}"
streetP.transform(_.mapString(_.reverse))(json).noSpacesSortKeys
// res3: String = "{\"address\":{\"street\":\"tS niaM\",\"zip\":12345},\"age\":30,\"name\":\"Alice\"}"

Forgiving semantics — 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.modify(_.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] = eo.circe.JsonPrism@105ad9f4

secondName.modify(_.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] = eo.circe.JsonTraversal@664484e9

everyName.modify(_.toUpperCase)(basketJson).noSpacesSortKeys
// res6: String = "{\"items\":[{\"name\":\"X\"},{\"name\":\"Y\"},{\"name\":\"Z\"}],\"owner\":\"Alice\"}"
everyName.getAll(basketJson)
// res7: Vector[String] = Vector("X", "Y", "Z")

Empty arrays and missing paths leave the Json unchanged.

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 the whole root record (and you have a Codec) codecPrism[Person].modify(f)

For the full failure-mode matrix (missing paths, non-array focuses, empty collections, out-of-range indices), see the behaviour spec.