cats-eo

ZIO integration

The cats-eo-zio module integrates eo's capability layer with ZIO 2's dependency-injection model. No new carrier ships in this module — both ZIO's DI substrate and its wiring functions already have optic shapes:

Three optional-dependency sub-packages extend the same treatment across the ZIO ecosystem (add the artifact yourself — the avro/circe/kyo-schema pattern):

libraryDependencies += "dev.constructive" %% "cats-eo-zio" % "0.16"

Which direction are you integrating?

The module is a two-way adapter; pick the row that matches what you are holding and what the other side expects:

You're holding The other side expects Reach for
an optic (lens[AppConfig](_.db)) a ZLayer in the dependency graph focusLayer — the optic becomes the layer's wiring function
an optic an environment transformation (overriding a dependency) service + .andThen handed to provideSomeEnvironment
a ZEnvironment, Exit, or Ref of either eo capability evidence (CanGet[T, A], CanModify[T, A], …) import dev.constructive.eo.zio.given — no hand-written given
a TRef / TMap focused updates that compose atomically STM focus ops — each op is a USTM
a Schema[A] a Lens per field, a Prism per case, a Traversal per collection makeAccessors(EoAccessorBuilder)
encoded bytes or a DynamicValue tree a targeted edit with no full decode codec.prism / schema.dynamicPrism + DynamicValues
a zio.json.ast.Json or a JsonCursor path eo navigation / composition JsonValues + cursor.optional
a ZValidation success / accumulated-error optics Validations

The service lens

service[R, A] focuses the A service slot inside a ZEnvironment[R]. It is a lawful lens because ZEnvironment is a type-indexed map: writing a slot replaces exactly that entry and the sibling services are the untouched leftover.

import zio.*
import dev.constructive.eo.*
import dev.constructive.eo.zio.*
import dev.constructive.eo.generics.lens

case class Db(url: String, pool: Int)
case class Metrics(prefix: String)

val env = ZEnvironment(Db("jdbc:h2", 4)).add(Metrics("eo"))

val dbL = service[Db & Metrics, Db]
dbL.get(env)
// res0: Db = Db(url = "jdbc:h2", pool = 4)

Because service returns the concrete fused lens class, it composes with ordinary field optics through the same .andThen as everywhere else — drill from the environment into a field of a service:

val poolL = lens[Db](_.pool)
// poolL: SimpleLens[Db, Int, NamedTuple[Tuple1["url"], Tuple1[String]]] = dev.constructive.eo.optics.SimpleLens@75dcb654

val dbPool = dbL.andThen(poolL)
// dbPool: Optic[ZEnvironment[Db & Metrics], ZEnvironment[Db & Metrics], Int, Int, [T1 >: Nothing <: Any, T2 >: Nothing <: Any] =>> Tuple2[T1, T2]] = repl.MdocSession$MdocApp$$anon$5@356f2ccf

dbPool.modify(_ * 2)(env).get[Db]
// res1: Db = Db(url = "jdbc:h2", pool = 8)

That composed optic is what you hand to provideSomeEnvironment — ZIO's own idiom for providing a different implementation of a service: tweak one field deep inside one service, leave the rest of the environment alone. Where ZIO's docs override a whole service, the drilled optic overrides one field of one service:

val poolOf: ZIO[Db & Metrics, Nothing, Int] = ZIO.serviceWith[Db](_.pool)
// poolOf: ZIO[Db & Metrics, Nothing, Int] = Stateful(
//   trace = "repl.MdocSession.MdocApp.poolOf(zio.md:57)",
//   onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x000000003ae78d80@59e17ba
// )

val throttled = poolOf.provideSomeEnvironment[Db & Metrics](dbPool.replace(1))
// throttled: ZIO[Db & Metrics, Nothing, Int] = Stateful(
//   trace = "repl.MdocSession.MdocApp.throttled(zio.md:61)",
//   onState = zio.FiberRef$unsafe$PatchFiber$$Lambda/0x000000003ae79fe8@275d50b7
// )

Unsafe.unsafe(implicit u =>
  Runtime.default.unsafe.run(throttled.provideEnvironment(env)).getOrThrowFiberFailure()
)
// res2: Int = 1

Layer projection

ZIO's idiom for deriving one service from another is a ZLayer whose construction function projects the dependency — see building the dependency graph and dependency propagation in the ZIO docs. That projection is a getter, so focusLayer[S, A] builds the layer from CanGet[S, A] evidence and slots into the same >>> / provide graphs as any hand-written layer — the optic is the wiring:

val urlL = lens[Db](_.url)

// One aggregate config service, one sub-service layer per field optic:
val urlLayer: ZLayer[Db, Nothing, String] = {
  given CanGet[Db, String] = urlL
  focusLayer[Db, String]
}
Unsafe.unsafe(implicit u =>
  Runtime.default.unsafe
    .run(ZIO.service[String].provideLayer(ZLayer.succeed(Db("jdbc:h2", 4)) >>> urlLayer))
    .getOrThrowFiberFailure()
)
// res3: String = "jdbc:h2"

serviceFocus[S, A] is the one-shot read of the same shape — ZIO.serviceWith[S] routed through CanGet instead of an ad-hoc projection lambda.

Ref focus ops

Runtime state held in a Ref[S] gets the same capability treatment. Each op demands the weakest capability it needs, and a read-then-write is ONE CanModify in a single atomic Ref.update pass — never split get + set evidence:

val program =
  for
    ref <- Ref.make(Db("jdbc:h2", 4))
    _   <- ref.updateFocus[String](_.toUpperCase)(using urlL)
    _   <- ref.setFocus(8)(using poolL)
    out <- ref.get
  yield out
// program: ZIO[Any, Nothing, Db] = FlatMap(
//   trace = "repl.MdocSession.MdocApp.program(zio.md:104)",
//   first = Sync(
//     trace = "repl.MdocSession.MdocApp.program(zio.md:100)",
//     eval = zio.Ref$$$Lambda/0x000000003aea86f8@76ad3d15
//   ),
//   successK = repl.MdocSession$MdocApp$$Lambda/0x000000003ae7f598@483af017
// )

Unsafe.unsafe(implicit u =>
  Runtime.default.unsafe.run(program).getOrThrowFiberFailure()
)
// res4: Db = Db(url = "JDBC:H2", pool = 8)

getFocus (via CanGet) and getFocusOption (via CanGetOption, for Prism / Optional / AffineFold evidence) complete the read side.

STM focus ops

The same four ops exist on TRef[S] (and keyed getFocusAt / updateFocusAt / setFocusAt on TMap[K, V]), returning USTM instead of UIO. That is the point, not a spelling difference: focused updates across several transactional references compose into ONE atomic transaction — something the Ref ops structurally cannot express:

import _root_.zio.stm.*

val syncPools =
  for
    primary <- TRef.make(Db("a", 1)).commit
    replica <- TRef.make(Db("b", 1)).commit
    _       <- STM.atomically(
                 primary.updateFocus[Int](_ + 9)(using poolL) *>
                   replica.updateFocus[Int](_ + 9)(using poolL)
               )
    out     <- primary.get.commit
  yield out
// syncPools: ZIO[Any, Nothing, Db] = FlatMap(
//   trace = "repl.MdocSession.MdocApp.syncPools(zio.md:129)",
//   first = Stateful(
//     trace = "repl.MdocSession.MdocApp.syncPools(zio.md:122)",
//     onState = zio.stm.ZSTM$$$Lambda/0x000000003aeb3360@4b8fd8ee
//   ),
//   successK = repl.MdocSession$MdocApp$$Lambda/0x000000003aead078@654c5b68
// )

Unsafe.unsafe(implicit u =>
  Runtime.default.unsafe.run(syncPools).getOrThrowFiberFailure()
)
// res5: Db = Db(url = "a", pool = 10)

On TMap, absent keys pass writes through untouched — no entry is invented, exactly the miss semantics of every partial optic in eo.

Automatic capability givens

ZIO's types can also provide eo capabilities by themselves. A given import (Scala 3's * wildcard deliberately does not pull givens) puts one optic given per pair in scope:

Generic capability-consuming code then accepts ZIO subjects with no hand-written given:

import dev.constructive.eo.zio.given

def report[T](t: T)(using g: CanGet[T, Db]): String = g.get(t).url
def bump[T](t: T)(using m: CanModify[T, Int]): T = m.modify(_ + 1)(t)

report(env)
// res6: String = "jdbc:h2"

bump(Exit.succeed(41))
// res7: Exit[Nothing, Int] = Success(42)

bump(Exit.fail("boom"): Exit[String, Int])
// res8: Exit[String, Int] = Failure(
//   Fail(
//     value = "boom",
//     trace = StackTrace(fiberId = None, stackTrace = IndexedSeq())
//   )
// )

Coherence rule as everywhere in eo: these are THE optic givens for their (S, A) pairs — don't declare competing ones.

Chunk element optics

Chunks.each / Chunks.at(i) / Chunks.eachNonEmpty are the collection legs everything below stands on (DynamicValue.Sequence, zio-json arrays, and BinaryCodec payloads all speak Chunk). zio ships no cats instances — the orphan Traverse[Chunk] given belongs to zio-interop-cats — so these are constructors over private adapters, per the constructor-not-given doctrine:

import dev.constructive.eo.zio.Chunks

Chunks.each[Int, Int].modify(_ + 1)(Chunk(1, 2, 3))
// res9: Chunk[Int] = IndexedSeq(2, 3, 4)

Chunks.at[Int](1).replace(9)(Chunk(1, 2, 3))
// res10: Chunk[Int] = IndexedSeq(1, 9, 3)

The zio-schema bridge

eo.zio.schema (add dev.zio %% "zio-schema" yourself). zio-schema ships AccessorBuilder — a first-class extension point where an optics library plugs itself in. EoAccessorBuilder fills it with eo optics: one call returns a fused Lens per record field, a Prism per enum case, a Traversal per collection, for any Schema[A], no macros involved:

import _root_.zio.schema.*
import dev.constructive.eo.zio.schema.*

case class Person(name: String, age: Int)
object Person:
  given schema: Schema[Person] = DeriveSchema.gen[Person]
val personCC = Person.schema.asInstanceOf[Schema.CaseClass2[String, Int, Person]]
// personCC: CaseClass2[String, Int, Person] = CaseClass2(Nominal(Chunk(repl),Chunk(MdocSession,MdocApp),Person), Field(name,$Lazy$),Field(age,$Lazy$))
val (personName, personAge) = personCC.makeAccessors(EoAccessorBuilder)
// personName: GetReplaceLens[Person, Person, String, String] = dev.constructive.eo.optics.GetReplaceLens@6324f25d
// personAge: GetReplaceLens[Person, Person, Int, Int] = dev.constructive.eo.optics.GetReplaceLens@4422dbbc

personAge.modify(_ + 1)(Person("ada", 41))
// res11: Person = Person(name = "ada", age = 42)

DynamicValues is the navigation kit for the untyped DynamicValue tree — what any Schema[A] encodes to before a codec turns it into bytes, so one kit covers every wire format zio-schema speaks. Constructor prisms (str, int, record, …, and the generic primitive(standardType)), sibling-preserving field / at / key / variant navigation, an each traversal, and a Plated instance for whole-tree rewrites. schema.dynamicPrism crosses between the two worlds:

import dev.constructive.eo.zio.schema.DynamicValues

val dyn = Person.schema.toDynamic(Person("ada", 41))
// dyn: DynamicValue = Record(
//   id = Nominal(
//     packageName = IndexedSeq("repl"),
//     objectNames = IndexedSeq("MdocSession", "MdocApp"),
//     typeName = "Person"
//   ),
//   values = ListMap(
//     "name" -> Primitive(value = "ada", standardType = string),
//     "age" -> Primitive(value = 41, standardType = int)
//   )
// )

DynamicValues.field("age").andThen(DynamicValues.int).modify(_ + 1)(dyn)
  .toTypedValue(using Person.schema)
// res12: Either[String, Person] = Right(Person(name = "ada", age = 42))

And the byte face: any BinaryCodec[A] — zio-schema-json, -protobuf, -avro, -msgpack, and -thrift all produce one — is a Prism between encoded bytes and A, with the usual byte-face laws (roundtrip identity one way, re-encode normalisation the other, misses pass through writes):

import _root_.zio.schema.codec.JsonCodec as ZJsonCodec

val personCodec = ZJsonCodec.schemaBasedBinaryCodec[Person](using Person.schema)
// personCodec: BinaryCodec[Person] = zio.schema.codec.JsonCodec$$anon$2@e1d7cd8

new String(
  personCodec.prism.modify(p => p.copy(age = p.age + 1))(
    personCodec.encode(Person("ada", 41))
  ).toArray
)
// res13: String = "{\"name\":\"ada\",\"age\":42}"

zio-json optics

eo.zio.json (add dev.zio %% "zio-json" yourself) is the circe playbook on zio.json.ast.Json: constructor prisms, field / at / each navigation, Plated, and JsonValues.text as the String ↔ Json on-ramp. Plus the seam circe has no analog for: zio-json's own JsonCursor is already a typed path, and cursor.optional turns any existing one into a sibling-preserving eo Optional — cursor-based codebases get eo composition without rewriting a path:

import _root_.zio.json.ast.{Json, JsonCursor}
import dev.constructive.eo.zio.json.*
import dev.constructive.eo.zio.json.JsonValues.text

val doc = Json.Obj(
  "name" -> Json.Str("ada"),
  "tags" -> Json.Arr(Json.Str("a"), Json.Str("b")),
)
// doc: Obj = Obj(
//   IndexedSeq(
//     ("name", Str("ada")),
//     ("tags", Arr(IndexedSeq(Str("a"), Str("b"))))
//   )
// )

JsonValues.field("name").andThen(JsonValues.str).getOption(doc)
// res14: Option[String] = Some("ada")

JsonCursor.field("tags").isArray.element(1).optional.replace(Json.Str("B"))(doc)
// res15: Json = Obj(
//   IndexedSeq(
//     ("name", Str("ada")),
//     ("tags", Arr(IndexedSeq(Str("a"), Str("B"))))
//   )
// )

text.andThen(JsonValues.field("name")).andThen(JsonValues.str)
  .modify(_.toUpperCase)("""{"name":"ada"}""")
// res16: String = "{\"name\":\"ADA\"}"

JsonCodec[A].stringPrism is the typed wire face — the same shape as AvroJson and the kyo byte faces.

Both kits also ship atField(name), the At-shaped sibling of field: its focus is the Option at that name, so a write can create or delete the field, which field structurally cannot (its focus is the value, so an absent field is a miss and misses pass writes through). Within an object it is total — presence lives in the focus — so getOption yields Some(None) for an object lacking the field:

JsonValues.atField("nickname").replace(Some(Json.Str("ada")))(doc)
// res17: Json = Obj(
//   IndexedSeq(
//     ("name", Str("ada")),
//     ("tags", Arr(IndexedSeq(Str("a"), Str("b")))),
//     ("nickname", Str("ada"))
//   )
// )

JsonValues.atField("name").replace(None)(doc)
// res18: Json = Obj(IndexedSeq(("tags", Arr(IndexedSeq(Str("a"), Str("b"))))))

ZValidation optics

eo.zio.prelude (add dev.zio %% "zio-prelude" yourself — zio-schema already carries it transitively). Validations.success is an Optional, not a Prism: a prism-shaped write would have to invent a log and would drop the one already there. Validations.eachFailure traverses every accumulated error and is polymorphic in the error type, so error translation is ordinary modify:

import _root_.zio.prelude.Validation
import dev.constructive.eo.zio.prelude.Validations

Validations.success[Nothing, String, Int].modify(_ * 2)(Validation.succeed(21))
// res19: ZValidation[Nothing, String, Int] = Success(
//   log = IndexedSeq(),
//   value = 42
// )

Validations.eachFailure[Nothing, String, String, Unit].modify(_.toUpperCase)(
  Validation.validate(Validation.fail("first"), Validation.fail("second")).map(_ => ())
)
// res20: ZValidation[Nothing, String, Unit] = Failure(
//   log = IndexedSeq(),
//   errors = NonEmptyChunk(FIRST, SECOND)
// )

Effectful modify

There is deliberately no modifyZIO in this module. CanModifyF.modifyF is polymorphic in its functor, and zio-interop-cats owns the cats.Functor[ZIO[R, E, *]] instance — so the integration is one import on your side of the classpath, with nothing for this module to duplicate:

// libraryDependencies += "dev.zio" %% "zio-interop-cats" % "23.1.0.5"
import zio.interop.catz.*

def refresh(url: String): Task[String] = ???

val refreshed: Db => Task[Db] = urlL.modifyF(refresh)

What the module does own is the plumbing interop-cats can't give you: Ref.Synchronized[S].updateFocusZIO(f) runs an effectful rewrite at the focus while the ref is held, so the read-modify-write stays atomic — the thing Ref cannot express at all. It takes your Applicative[ZIO[R, E, *]] (hence no new dependency here) and CanModifyA, so it works through a lens, a prism (a miss means the effect never runs), or a traversal (one effect per focus, sequenced):

val ref: Ref.Synchronized[Db] = ???

ref.updateFocusZIO(refresh)(using urlL, summon)

Overhead

ZioDiBench pairs every op above (leaf service get/replace, drilled get/modify) against the hand-written env.get / env.update equivalent. Both sides pay ZEnvironment's own map machinery — and the optic side measures cheaper: reads are allocation-free (0 B/op vs ~120 B/op hand-written) and writes allocate less, because the lens captures its zio.Tag once at construction while every hand-written env.get[A] / env.update[A] call site re-materializes it. See the benchmarks page for the current sweep.