cats-eo

chimney integration

The cats-eo-chimney module connects eo to chimney, the Scala library that derives conversions between similar data types. The two tools do different jobs. chimney turns a whole S into a T; eo points at one part of a structure and reads or writes it. This module turns chimney's derived values into eo optics, so a chain can convert first and then keep going into fields of the target. It also lets eo run chimney conversions and patches at a focus, or on what an optic writes back.

libraryDependencies += "dev.constructive" %% "cats-eo-chimney" % "0.18"

The module brings in chimney (2.1.0) itself. eo-generics is not a dependency; the examples below use it for field lenses, so add cats-eo-generics if you want the same.

chimney value eo optic Writes?
Transformer[S, T] Getter[S, T] no
Iso[A, B] Iso[A, B] yes
Codec[Domain, Dto] prism from Dto to Domain yes
PartialTransformer[S, T] ForgetFold[S, PartialResult, T] no
Patcher[A, P] Modify[A, A, Unit, P] yes

Chimney.getter / iso / prism / resultFold / patching summon or derive the chimney value. Every chimney value also has a .toGetter / .toIso / .toPrism / .toResultFold() / .toModify extension, for values you configured by hand (Transformer.define[…] .withFieldRenamed(…).buildTransformer). They are plain constructors, not givens: you declare the optic you want, as everywhere else in eo.

import dev.constructive.eo.chimney.*
import dev.constructive.eo.docs.chimney.*
import dev.constructive.eo.generics.lens
import dev.constructive.eo.optics.Optic.*

Convert, then read a field

A Transformer[S, T] becomes a Getter[S, T]. Compose it with lenses on T to read a field of the converted value:

val viewCity =
  Chimney
    .getter[User, UserView]
    .andThen(lens[UserView](_.address))
    .andThen(lens[AddressView](_.city))

val ada = User("Ada", "hunter2", Address("1 Analytical St", "London"))
viewCity.get(ada)
// res0: String = "London"

chimney derives the Transformer here; with one in scope, that one is used. The chain can only read: a Transformer has no way back from T to S. For that, use one of the two-way chimney types below.

Write through a conversion

chimney's Iso[A, B] (two total transformers) becomes an eo Iso, so the chain can modify and land back in A:

val rowAge = Chimney.iso[Person, PersonRow].andThen(lens[PersonRow](_.age))
// rowAge: Optic[Person, Person, Int, Int, [X >: Nothing <: Any, A >: Nothing <: Any] =>> Tuple2[X, A]] = repl.MdocSession$MdocApp$$anon$7@4badc931

rowAge.modify(_ + 1)(Person("Ada", 36))
// res1: Person = Person(name = "Ada", age = 37)

chimney's Codec[Domain, Dto] encodes totally but may fail to decode. It becomes a prism from the DTO to the domain value: getOption decodes and reverseGet encodes. Composed with a lens, it edits a domain field directly on the DTO. A DTO that does not decode is passed through unchanged, which is how eo prisms always behave on a miss:

val owner = Chimney.prism[Account, AccountDto].andThen(lens[Account](_.owner))
// owner: Optional[AccountDto, AccountDto, String, String] = dev.constructive.eo.optics.Optional$Fused@51d25b4e

owner.modify(_.toUpperCase)(AccountDto("ada", 120L))
// res2: AccountDto = AccountDto(owner = "ADA", balance = 120L)
owner.modify(_.toUpperCase)(AccountDto("bob", -5L))
// res3: AccountDto = AccountDto(owner = "bob", balance = -5L)

These optics are lawful only if chimney's two directions really invert each other: decode after encode must return the domain value, and encode after decode must return the DTO. Check your own instances with cats-eo-laws' IsoTests and PrismTests, as this module's own specs do.

Read with errors: a fold into the result

A PartialTransformer[S, T] becomes a core ForgetFold whose container is chimney's result: a ForgetFold[S, PartialResult, T]. (PartialResult is chimney's own partial.Result under another name, so it can carry the cats instances the fold uses; at runtime it is the same value.) It reads at most one focus, like AffineFold, but on a miss it keeps chimney's errors, including the path of each failing field. .result(s) returns the partial.Result, .either(s) an Either, and .getOption(s) the focus without the errors:

val decode = Chimney.resultFold[AccountDto, Account]
// decode: ForgetFold[AccountDto, PartialResult, Account] = dev.constructive.eo.optics.ForgetFold@41f1ce97

decode.result(AccountDto("bob", -5L)).asErrorPathMessageStrings.toList
// res4: List[Tuple2[String, String]] = List(
//   ("balance", "negative amount: -5")
// )

The errors survive composition with a single-focus optic, because a core fold stays in its container when the container allows it. andThen with a lens, iso or Getter maps the result. andThen with a prism, optional or AffineFold maps it too, an inner miss becoming chimney's empty-value error. andThen with another result fold combines both sides' errors:

val decodedOwner = decode.andThen(lens[Account](_.owner))
// decodedOwner: ForgetFold[AccountDto, PartialResult, String] = dev.constructive.eo.optics.ForgetFold@342c3b56

decodedOwner.either(AccountDto("ada", 120L))
// res5: Either[Errors, String] = Right("ada")
decodedOwner.either(AccountDto("bob", -5L)).left.map(_.asErrorPathMessageStrings.toList)
// res6: Either[List[Tuple2[String, String]], String] = Left(
//   List(("balance", "negative amount: -5"))
// )

The same holds for any core fold whose container has the matching cats instance: a fold into Option or Either[E, *] stays one through a lens.

A Traversal or a fold over a collection has many foci, so there is no single result whose errors could be kept; composing with one gives a List fold. To compose without the errors, start from .affineFold. .toResultFold(failFast = true) stops at the first error, as chimney's transformFailFast does.

Patch a nested part

A Patcher[A, P] merges the fields of a patch value P into an A. optic.patch(p) applies it wherever the optic points. eo finds the part, and chimney merges the patch into it:

val order = Order(1L, Customer("Ada", "ada@old", "000"))
// order: Order = Order(
//   id = 1L,
//   customer = Customer(name = "Ada", email = "ada@old", phone = "000")
// )

lens[Order](_.customer).patch(ContactPatch("ada@new", "111"))(order)
// res7: Order = Order(
//   id = 1L,
//   customer = Customer(name = "Ada", email = "ada@new", phone = "111")
// )

Chimney.patching[A, P] returns the same operation as a stored, write-only optic (Modify[A, A, Unit, P], where replace(p) applies the patch), so it can be composed after any writable optic:

val contact = lens[Order](_.customer).andThen(Chimney.patching[Customer, ContactPatch])
// contact: Modify[Order, Order, Unit, ContactPatch] = dev.constructive.eo.optics.Modify@4128598e

contact.replace(ContactPatch("e", "p"))(order)
// res8: Order = Order(
//   id = 1L,
//   customer = Customer(name = "Ada", email = "e", phone = "p")
// )

If the optic misses (a prism or optional), the value passes through unchanged. A Traversal patches every focus.

Change the focus's write type

optic.transformFocus[C] returns a new optic that is written with a C instead of the B the original optic writes. chimney's Transformer[C, B] turns each written C into a B. Reads are unchanged: the new optic is an Optic[S, T, A, C], so the focus still reads as an A. This works on eo-generics' derived lenses, which keep one type for reading and writing; here a lens to an Address is written with an AddressView:

val addressAsView = lens[User](_.address).transformFocus[AddressView]
// addressAsView: Optic[User, User, Address, AddressView, [T1 >: Nothing <: Any, T2 >: Nothing <: Any] =>> Tuple2[T1, T2]] = dev.constructive.eo.chimney.package$package$$anon$1@4dbf893c

addressAsView.replace(AddressView("Paris", "2 Rue"))(ada)
// res9: User = User(
//   name = "Ada",
//   password = "hunter2",
//   address = Address(street = "2 Rue", city = "Paris")
// )
addressAsView.get(ada)
// res10: Address = Address(street = "1 Analytical St", city = "London")

On an optic that can change its focus type (Traversal.pEach, Lens.pLens, Prism.pPrism, Traversal.first / second, …), transformFocus lets an edit be made in the type the optic reads. The Traversal below reads RawPrices and writes Prices; transformFocus[RawPrice] lets it be written with RawPrices, so one write reprices every element and converts it:

import dev.constructive.eo.optics.Traversal

val prices = Traversal.pEach[List, RawPrice, Price]
prices.transformFocus[RawPrice].modify(r => RawPrice(r.cents * 2))(List(RawPrice(100), RawPrice(250)))
// res11: List[Price] = List(Price(200L), Price(500L))

transformFocusPartial[C] does the same with a PartialTransformer[C, B]. A write returns the new T, or the errors from every written value that didn't convert; across a Traversal's foci the errors accumulate rather than stopping at the first one. The original optic's own write runs only when every value converted, so it never sees a failed one:

prices
  .transformFocusPartial[RawPrice]
  .modify(r => RawPrice(r.cents - 150))(List(RawPrice(100), RawPrice(250), RawPrice(120)))
  .asErrorPathMessageStrings
  .toList
// res12: List[Tuple2[String, String]] = List(
//   ("", "negative price: -50"),
//   ("", "negative price: -30")
// )

Each error's path is relative to its focus, because chimney does not know where the optic was pointing.

Change the result type

optic.transformImage[U] changes the other write-side type: the result. It runs chimney's Transformer[T, U] on whatever the optic writes back, so every write returns a U instead of a T. Reads are unchanged. Combined with transformFocus, both write-side types change: lens[User](_.address).transformFocus[AddressView] .transformImage[UserView] is written with an AddressView and returns a UserView. Here a lens edits a User and returns the public view:

lens[User](_.name).transformImage[UserView].modify(_.toUpperCase)(ada)
// res13: UserView = UserView(
//   name = "ADA",
//   address = AddressView(city = "London", street = "1 Analytical St")
// )

A prism or optional that misses still returns a U: the unchanged value, transformed. transformImagePartial[U] does the same with a PartialTransformer[T, U], so a write returns the U or the errors from converting into it — edit the DTO, then validate it into the domain:

val balance = lens[AccountDto](_.balance).transformImagePartial[Account]
// balance: Optic[AccountDto, Result[Account], Long, Long, [T1 >: Nothing <: Any, T2 >: Nothing <: Any] =>> Tuple2[T1, T2]] = dev.constructive.eo.optics.Optic$outerProfunctor$$anon$1@78ee2dc2

balance.modify(_ + 1)(AccountDto("ada", 120L)).asOption
// res14: Option[Account] = Some(Account(owner = "ada", balance = Cents(121L)))
balance.modify(_ - 200)(AccountDto("ada", 120L)).asErrorPathMessageStrings.toList
// res15: List[Tuple2[String, String]] = List(
//   ("balance", "negative amount: -80")
// )

Both return a generic Optic, so .modify, .replace and .reverseGet keep working, but the concrete optic's fused fast paths are not carried over.

Performance

ChimneyBench (in benchmarks/) runs every scenario three ways: through this module (eo-chimney), with chimney alone (the same transformers, built once; map, copy or the into…withFieldComputed DSL where the job needs more than one transformation), and hand-written with neither library. All three compute the same value, including the intermediate values: the hand-written code builds the same view, domain value or DTO the other two do. Optics and transformers are built once, outside the measured call. The rows marked "…" run the same operation on inputs that fail.

Scenario eo-chimney B/op chimney B/op hand B/op eo ns/op chimney ns/op hand ns/op
transformFocus — reprice ×2 and retype 64 list elements 5,464 6,144 3,072 1527 1442 3719
transformFocusPartial — same, validating 5,592 6,256 4,624 1607 1685 972
… half the elements invalid 7,317 10,616 3,088 1724 2421 644
transformFocus — write a view through a derived lens 48 48 48 12 10 10
transformImage — edit a field, return the view 96 152 96 28 38 24
transformImagePartial — edit, then validate 120 96 64 30 24 12
… the edit fails validation 184 144 0 48 34 1
Chimney.getter ∘ two lenses — read a field 48 0 0 22 1 1
Chimney.prism ∘ lens — edit a DTO field 152 128 72 47 49 21
… the DTO doesn't decode 160 72 0 39 20 1
result fold ∘ lens — decode, read, keep errors 96 72 16 26 18 3
… the DTO doesn't decode 160 160 16 37 34 3
lens.patch — patch a nested part 48 48 48 11 10 9

One run per row (JDK 25, -i 5 -wi 3 -f 3 -prof gc) on a shared 4-vCPU machine. As on the benchmarks page, allocation (B/op) is the figure to compare; nanoseconds only show the direction, and several error bars are as wide as the number itself.

What the numbers say:

Reproduce with:

sbt "benchmarks/Jmh/run -i 5 -wi 3 -f 3 -prof gc .*ChimneyBench.*"

Notes