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.