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:
TypeMap[R](whatEnv[R]carries, whatEnv.runAllaccepts) is a type-indexed map, so every tagged slot is a lawful Lens (service).Layer.fromtakes a wiring functionS => A < *— exactlyCanGet[S, A]— so aggregate services project to sub-service layers (Layer.focus).Var[S]state gets the same capability-driven focus ops aszio.Ref(Var.getFocus/updateFocus/setFocus).
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 only — Env, 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" % "1.0"
Experimental. cats-eo-kyo is published with every release but sits
outside eo's 1.x binary-compatibility promise: it builds on kyo
1.0.0-RC7, a release candidate, so its API and bytecode can change in
any 1.x release, and MiMa does not check it. Every other eo module is
MiMa-checked across 1.x. The exclusion lifts once kyo 1.0.0 final ships
and the module is rebuilt on it. It also needs JDK 25 (see
JDK requirement).
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@7615227c
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] = Defer(Kyo((kyo.Memo$package$.Memo >: scala.Nothing <: kyo.Var[kyo.Memo$package$.Memo$.Cache]), <internal>.<internal>(<internal>:0:0)), this(?.runLayer(kyo.md:69:12)), Id)
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]] = Defer(Kyo(kyo.Var[repl.MdocSession$.MdocApp.Db], ?.updateFocus(kyo.md:130:66)), this(?.url(kyo.md:133:12)), Id)
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@19d31834
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 dependency — cats-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-RC7"
libraryDependencies += "io.getkyo" %% "kyo-schema-json" % "1.0.0-RC7"
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 = VariantCase(
// name = "Circle",
// value = Record(Seq(("radius", Decimal(2.5))))
// )
variant("Circle").andThen(field("radius")).andThen(decimal).modify(_ * 2)(shapeV)
// res22: Value = VariantCase(
// name = "Circle",
// value = 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@31028b59
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:
(TypeMap[R], A)for every tagged slotAofR— theservicelens;(Maybe[A], A)— aPresentprism;(Result[E, A], A)— a success prism; failures and panics pass through writes untouched.
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.