cats-eo

Kyo integration

The cats-eo-kyo module integrates eo with Kyo's dependency management at the same three seams as the ZIO module — the two systems' DI substrates are both type-indexed maps, and the integration surface mirrors it deliberately:

Kyo's surface is companion-static (Env.get, Var.update), so the focus ops here extend the companion objects and read like native Kyo.

The module depends on kyo-prelude onlyEnv, Var, Layer and TypeMap all live in Kyo's dependency-light pure layer (kyo-data and kyo-kernel come transitively). No kyo-core IO runtime is pulled in, so the module is usable from pure kyo-prelude programs and full kyo-core applications alike.

libraryDependencies += "dev.constructive" %% "cats-eo-kyo" % "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[Config](_.maxOrders)) an Env read (Dependencies) Env.focus instead of an Env.use projection lambda
an optic a Layer in the wiring graph (Wiring with layers) Layer.focus — the optic becomes the layer's wiring function
an optic a TypeMap for Env.runAll overrides service + .andThen
a TypeMap, Maybe, Result, or Var state eo capability evidence (CanGet[T, A], CanModify[T, A], …) import dev.constructive.eo.kyo.given — no hand-written given
a NamedTuple or case class a kyo Record (or back) Record.iso[T] — a staged bijection
a Record[F] one field, optic-shaped Record.lens[F]("name")
a kyo-schema Schema[A] / Focus eo optics over values or encoded payloads the eo.kyo.schema bridge (optional dependency)
an untyped Structure.Value tree navigation, rewrites, or a typed leaf — without decoding the spine StructureValues + Schema.valuePrism

The service lens

service[R, A] focuses the A slot inside a TypeMap[R] — the direct analogue of the ZIO module's ZEnvironment service lens, and lawful for the same reason: add on a present tag replaces exactly that entry, sibling slots are the untouched leftover.

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

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

val tm = TypeMap(Db("jdbc:h2", 4), Metrics("eo"))

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

It composes with field optics through the ordinary .andThen, which is what you want for Env.runAll test overrides — tweak one field of one service in an environment map, leave the rest alone:

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

dbL.andThen(poolL).modify(_ * 2)(tm).get[Db]
// res1: Db = Db(url = "jdbc:h2", pool = 8)

Env and Layer focus

Env.focus[R, A] reads a focus out of the R service in the environment — Env.use routed through CanGet instead of an ad-hoc projection lambda:

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

given CanGet[Db, String] = urlL
Env.run(Db("jdbc:h2", 4))(Env.focus[Db, String]).eval
// res2: String = "jdbc:h2"

Layer.focus[S, A] derives an A service layer from an S service by focusing — Layer.from is CanGet-shaped, so the optic is the wiring. It slots straight into Env.runLayer graphs:

val prog = Env.runLayer(Layer(Db("jdbc:h2", 4)), Layer.focus[Db, String])(Env.get[String])
// prog: <[String, Memo] = Kyo(kyo.Var[kyo.Memo$package$.Memo$.Cache], Input(kyo.Var$internal$Get$@11ca057a), kyo.md:69:12, 2", 4)), Layer.focus[Db, String])(Env.get[String]))

Memo.run(prog).eval
// res3: String = "jdbc:h2"

Kyo docs examples through optics

The Dependencies section of the kyo-prelude docs reads a limit out of a config service with a projection lambda:

// from the kyo docs:
case class Config(maxOrders: Int, currency: String)

val limit: Int < Env[Config] =
    Env.use[Config](_.maxOrders)

With an optic naming the field, the same value is Env.focus — and the optic is reusable everywhere else the field is touched (writes, Var updates, layer wiring), not just in this one lambda:

case class Config(maxOrders: Int, currency: String)

val maxOrdersL = lens[Config](_.maxOrders)
val currencyL = lens[Config](_.currency)

given CanGet[Config, Int] = maxOrdersL

val limit: Int < Env[Config] = Env.focus[Config, Int]
Env.run(Config(5, "EUR"))(limit).eval
// res4: Int = 5

The Wiring with layers section builds a configLayer and derives further layers from it with Layer.from. When the derived service is a focus of the aggregate, Layer.focus replaces the hand-written projection — same graph, same Env.runLayer resolution:

val configLayer: Layer[Config, Any] =
  Layer(Config(maxOrders = 10, currency = "USD"))

val currencyLayer: Layer[String, Env[Config]] = {
  given CanGet[Config, String] = currencyL
  Layer.focus[Config, String]
}
Memo.run(Env.runLayer(configLayer, currencyLayer)(Env.get[String])).eval
// res5: String = "USD"

Var focus ops

Var[S] is Kyo's stateful effect; the focus ops mirror the ZIO module's Ref extensions with the same names and the same capability demands. A read-then-write is ONE CanModify in a single Var.updateDiscard pass:

val update =
  for
    _   <- Var.updateFocus[Db, String](_.toUpperCase)(using urlL)
    _   <- Var.setFocus[Db, Int](8)(using poolL)
    url <- Var.getFocus[Db, String]
  yield url
// update: <[String, Var[Db]] = Kyo(kyo.Var[repl.MdocSession$.MdocApp.Db], Input(dev.constructive.eo.kyo.KyoOptics$package$$anon$5@3abc9ebf), kyo.md:133:12, yield url)

Var.runTuple(Db("jdbc:h2", 4))(update).eval
// res6: Tuple2[Db, String] = (Db(url = "JDBC:H2", pool = 8), "JDBC:H2")

getFocusOption (via CanGetOption) covers partial foci — Prism, Optional and AffineFold evidence.

Records: Record.iso and Record.lens

Kyo's Record is a string-keyed typed record — Record["name" ~ String & "age" ~ Int] — and a NamedTuple or case class of the same shape is the same data. Record.iso[T] names that bijection, and it is a staged macro: the expansion is exactly the code you would write by hand for the concrete shape (("name" ~ t._1) & ("age" ~ t._2) one way, getField reads under kyo's own Fields.Have evidence the other), so there is no runtime Fields machinery, no per-call iteration, and no arity ceiling — beyond 22 fields the named-tuple expansion rides the same scala.runtime.Tuples calls the stdlib's Tuple#apply compiles to.

type PersonR = "name" ~ String & "age" ~ Int

val personI = Record.iso[(name: String, age: Int)]
personI.get((name = "Alice", age = 30)).name
// res7: String = "Alice"

personI.reverseGet("name" ~ "Ada" & "age" ~ 36)
// res8: NamedTuple[Tuple2["name", "age"], Tuple2[String, Int]] = ("Ada", 36)

Case classes take the same call — the rebuild goes through the primary constructor (new, not copy), so enum cases work too, and generic case classes are fine at concrete instantiations. (Customer is a plain case class Customer(name: String, age: Int) hosted at package level — the macro's new T(...) needs top-level targets, the same rule as the generics macros.)

import dev.constructive.eo.docs.Customer

val customerI = Record.iso[Customer]
customerI.get(Customer("Grace", 45)).age
// res9: Int = 45

Record.lens[F]("name") focuses one field, GenLens-style — the focus type is inferred from the same Fields.Have evidence that types record.name itself, and the replace is kyo's right-biased & merge. It composes with the iso like any other eo optic, and serves capability-consuming code:

val ageL = Record.lens[PersonR]("age")
val ageInCustomer = customerI.andThen(ageL)
// ageInCustomer: GetReplaceLens[Customer, Customer, Int, Int] = dev.constructive.eo.optics.GetReplaceLens@17762bb5

ageInCustomer.modify(_ + 1)(Customer("Grace", 45))
// res10: Customer = Customer(name = "Grace", age = 46)

reverseGet ∘ get is the identity; get ∘ reverseGet is kyo's compact — extra entries a widened record carries are dropped, not preserved. Field lenses are construction-only (no automatic givens): the field name is not part of the (S, A) capability key, so two same-typed fields would collide.

The kyo-schema bridge: eo.kyo.schema

kyo-schema derives a Schema[A] carrying structure, validation, wire configuration, and codecs. The dev.constructive.eo.kyo.schema sub-package turns that machinery into eo optics. It is an optional dependencycats-eo-kyo does not pull kyo-schema transitively; add it (and a codec artifact, here json) yourself:

libraryDependencies += "io.getkyo" %% "kyo-schema" % "1.0.0-RC6"
libraryDependencies += "io.getkyo" %% "kyo-schema-json" % "1.0.0-RC6"

Focus bridge

kyo-schema's Focus[Root, Value, Mode] is its own schema-checked optic, behind a mode lattice — and each mode maps onto the matching eo carrier: Focus.Id (product paths) → Lens, Maybe (sum-variant paths) → Optional, Chunk (collection paths) → Traversal (kyo's Chunk IS a Seq, so the mode is a lens onto the collection slot composed with Traversal.each). Bridged optics compose with everything else on this page. (KyoItem / KyoCart / KyoShape are package-level ADTs with derives Schema.)

import dev.constructive.eo.kyo.schema.*
import dev.constructive.eo.docs.{KyoCart, KyoItem, KyoShape}

val priceL = Schema[KyoItem].focus(_.price).lens
val radiusO = Schema[KyoShape].focus(_.Circle.radius).toOptional
val itemsT = Schema[KyoCart].foreach(_.items).traversal

val cart = KyoCart("c-1", Vector(KyoItem("apple", 1.0), KyoItem("pear", 2.0)))
priceL.modify(_ * 2)(KyoItem("apple", 1.0))
// res11: KyoItem = KyoItem(name = "apple", price = 2.0)

radiusO.getOption(KyoShape.Circle(2.5))
// res12: Option[Double] = Some(2.5)

radiusO.replace(9.9)(KyoShape.Square(4.0)) // miss: passes through untouched
// res13: KyoShape = Square(4.0)

itemsT.foldMap(_.price)(cart)
// res14: Double = 3.0

itemsT.andThen(priceL).modify(_ + 0.5)(cart).items
// res15: Vector[KyoItem] = Vector(
//   KyoItem(name = "apple", price = 1.5),
//   KyoItem(name = "pear", price = 2.5)
// )

Codec byte faces

Schema[A].encode / decode under any kyo codec (json, msgpack, protobuf, …) are exactly a Prism's two halves: prism[C] over the encoded Span[Byte], stringPrism[C] over the encoded String. decode's Result folds straight into the prism's Either tear — failures and panics are the miss arm, carrying the original input back losslessly. Compose with a bridged Focus lens to read/modify a field inside an encoded payload in one expression:

val itemJsonP = Schema[KyoItem].stringPrism[Json]
itemJsonP.reverseGet(KyoItem("apple", 1.0))
// res16: String = "{\"name\":\"apple\",\"price\":1.0}"

itemJsonP.andThen(priceL).modify(_ * 10)("""{"name":"apple","price":1.0}""")
// res17: String = "{\"name\":\"apple\",\"price\":10.0}"

itemJsonP.getOption("not json") // miss
// res18: Option[KyoItem] = None

itemJsonP.modify(identity)("not json") // misses pass writes through untouched
// res19: String = "not json"

Lawfulness caveats, same as the avro/circe bridges: get ∘ reverseGet is the identity (the codec roundtrip law); reverseGet ∘ get re-encodes, so byte-level layout normalizes. Decoding consumes ONE value — trailing input is accepted on reads and dropped by rewrites.

The untyped tree: Structure.Value

Every Schema[A] encodes to kyo's untyped Structure.Value tree before a codec turns it into bytes — kyo's answer to circe's Json, except one tree round-trips through every codec. StructureValues ports the circe module's playbook to that tree: one constructor prism per Value case (str, integer, decimal, record, sequence, …), sibling-preserving field/at/key navigation, an each traversal, a Plated[Value] for whole-document rewrites — plus variant(name), sum navigation circe has no analog for. And Schema[A].valuePrism is the typed ↔ untyped face beside prism/stringPrism: decode only the leaf you touch, leave the spine untyped.

import dev.constructive.eo.kyo.schema.StructureValues.{*, given}

val cartV = Structure.encode(cart)
field("id").getOption(cartV)
// res20: Option[Value] = Some(Str("c-1"))

// Untyped spine, typed leaves — only KyoItem is ever decoded:
Structure.decode[KyoCart](
  field("items").andThen(each).andThen(Schema[KyoItem].valuePrism)
    .modify(i => i.copy(price = i.price + 0.5))(cartV)
)
// res21: Result[DecodeException, KyoCart] = KyoCart(
//   id = "c-1",
//   items = Vector(
//     KyoItem(name = "apple", price = 1.5),
//     KyoItem(name = "pear", price = 2.5)
//   )
// )

// Sum navigation on the untyped side:
val shapeV = Structure.encode[KyoShape](KyoShape.Circle(2.5))
// shapeV: Value = Record(
//   Seq(("Circle", Record(Seq(("radius", Decimal(2.5))))))
// )
variant("Circle").andThen(field("radius")).andThen(decimal).modify(_ * 2)(shapeV)
// res22: Value = Record(
//   Seq(("Circle", Record(Seq(("radius", Decimal(5.0))))))
// )

// `atField` focuses the Option at a name, so a write can CREATE or DELETE
// the field — `field` can do neither (its miss passes writes through):
atField("note").replace(Some(Structure.Value.Str("gift")))(cartV)
// res23: Value = Record(
//   Seq(
//     ("id", Str("c-1")),
//     (
//       "items",
//       Sequence(
//         Seq(
//           Record(Seq(("name", Str("apple")), ("price", Decimal(1.0)))),
//           Record(Seq(("name", Str("pear")), ("price", Decimal(2.0))))
//         )
//       )
//     ),
//     ("note", Str("gift"))
//   )
// )

atField("id").replace(None)(cartV)
// res24: Value = Record(
//   Seq(
//     (
//       "items",
//       Sequence(
//         Seq(
//           Record(Seq(("name", Str("apple")), ("price", Decimal(1.0)))),
//           Record(Seq(("name", Str("pear")), ("price", Decimal(2.0))))
//         )
//       )
//     )
//   )
// )

field and key target the '''first''' matching name — Record and MapEntries are Chunk-backed, so duplicates are representable, and read-first/write-all would break modify(identity) on such a tree. atField is lawful up to field order and no further: deleting destroys the position, so a later insert appends rather than restoring it in place. That is inherent to At-style access over an ordered record — Monocle's At sidesteps it only because Map has no order.

Plated.transform / rewrite / universe walk the whole tree, any schema, any depth:

import dev.constructive.eo.optics.Plated

Plated.transform[Structure.Value] {
  case Structure.Value.Str(s) => Structure.Value.Str(s.toUpperCase)
  case other                  => other
}(cartV)
// res25: Value = Record(
//   Seq(
//     ("id", Str("C-1")),
//     (
//       "items",
//       Sequence(
//         Seq(
//           Record(Seq(("name", Str("APPLE")), ("price", Decimal(1.0)))),
//           Record(Seq(("name", Str("PEAR")), ("price", Decimal(2.0))))
//         )
//       )
//     )
//   )
// )

And because kyo ships a public Schema[Structure.Value], the byte faces above apply to the untyped tree itself — edit one field inside an encoded payload with no typed value materialised, under any codec:

val valueJsonP = summon[Schema[Structure.Value]].stringPrism[Json]
// valueJsonP: MendTearPrism[String, String, Value, Value] = dev.constructive.eo.optics.MendTearPrism@2b1ac575

valueJsonP.andThen(field("id")).andThen(str).modify(_.toUpperCase)(
  Schema[KyoCart].stringPrism[Json].reverseGet(cart)
)
// res26: String = "{\"id\":\"C-1\",\"items\":[{\"name\":\"apple\",\"price\":1.0},{\"name\":\"pear\",\"price\":2.0}]}"

One wire-format wart: RC6's Structure.encode spells a sum as a single-field wrapper record (Record(Chunk(("Circle", payload)))) and never emits the Value.VariantCase case declared for sums — kyo's own Path.Variant navigation matches only VariantCase, so it can't see the encoder's output (getkyo/kyo#1860). variant(name) accepts both spellings and rebuilds whichever it read, so it keeps working whichever way upstream resolves it.

JDK requirement

kyo 1.0.0-RC5+ ships Java-25-only bytecode, and macros execute inside the compiler's JVM — so compiling or running against cats-eo-kyo requires JDK 25, unlike the rest of eo (JDK 17 floor). In this repo the kyo module drops out of the root aggregate on older JVMs and CI runs a dedicated 25 lane.

Automatic capability givens

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

import dev.constructive.eo.kyo.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(tm)
// res27: String = "jdbc:h2"

bump(Maybe(41))
// res28: Maybe[Int] = 42

bump(Result.fail("e"): Result[String, Int])
// res29: Result[String, Int] = Failure("e")

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

Explicit nulls

Kyo's inline kernel is not -Yexplicit-nulls-clean: its machinery expands inside your compilation units wherever a kyo inline def is used (.eval, Env.run, …) and passes a null Safepoint interceptor, which fails to typecheck under that flag. This is a property of Kyo, not of this module — but it means projects enabling -Yexplicit-nulls cannot currently compile kyo call sites, with or without eo. (cats-eo-kyo itself opts out of the flag that the rest of the eo build carries.)

Overhead

KyoDiBench pairs the ops above against their hand-written equivalents in two tiers: pure TypeMap ops (tm.get / tm.add), and full effectful round-trips (Env.focus vs Env.use, Var.updateFocus vs Var.updateDiscard) where both sides pay Kyo's suspend/handle/eval machinery and the pair isolates the capability hop. Measured: allocation parity on the map tier (reads 0 B/op both sides, drilled writes identical), and +8–16 B/op on the effect tier — the per-call capability closure. See the benchmarks page for the current sweep.