cats-eo

Capabilities

The capability traits — CanGet, CanGetOption, CanReverseGet, CanModify, CanFold, CanPut, CanModifyF, CanModifyA, CanPlace, CanTransform — are the consuming surface of cats-eo, living at the top of the package hierarchy (dev.constructive.eo) because they are how most code should reference optics:

Consume via capability, construct via optic. A method that uses an optic takes the weakest capability trait that covers what it does. Concrete optic types (Lens, Prism, Getter, …) appear where optics are built and composed.

Late binding

A capability erases everything about the optic except what the consumer needs — the carrier F[_, _] and the existential leftover X are gone, leaving a plain two-to-four-parameter trait. That makes it a legal, ergonomic using parameter, which is what unlocks the pattern this library was written for: leave the subject type generic, and demand only the evidence.

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

import java.time.{Duration, Instant}

// This function knows NOTHING about the shape of T — only that an
// Instant can be rewritten inside it.
def adjustTimes[T](delta: Duration)(using cm: CanModify[T, Instant]): T => T =
  cm.modify(_.plus(delta))

// Each domain type supplies its own evidence — a derived lens
// IS the capability, so the given can be declared at the
// capability type directly.
case class Meeting(title: String, start: Instant)
given CanModify[Meeting, Instant] = lens[Meeting](_.start)
val shift = adjustTimes[Meeting](Duration.ofHours(1))
// shift: Function1[Meeting, Meeting] = dev.constructive.eo.optics.SplitCombineLens$$Lambda/0x0000000096902d38@39c1cc22
shift(Meeting("standup", Instant.EPOCH))
// res0: Meeting = Meeting(title = "standup", start = 1970-01-01T01:00:00Z)

The module that defines adjustTimes never depends on Meeting — new types opt in by bringing an optic given (or passing one explicitly at the call site with (using myLens)).

The capability × family matrix

Every concrete optic class implements its capabilities directly, so a capability call on a Lens, Prism, Getter, … dispatches straight into the same fused method a direct call uses — no wrapper, no allocation (see Benchmarks). Optics that implement no capability directly — results of generic composition, and the concrete Traversal class, which declares no capability mixins yet — are served by a derived given in each capability's companion instead — a thin wrapper delegating to the same extension methods.

Capability Operations Lens Iso Prism Optional Traversal Getter AffineFold Fold Review Modify
CanGet[S, A] get
CanGetOption[S, A] getOption
CanReverseGet[T, B] reverseGet
CanModify[S, A]¹ modify, replace
CanFold[S, A] foldMap, headOption, length, exists, foci
CanPut[T, A]¹ put
CanModifyF[S, A]¹ modifyF
CanModifyA[S, A]¹ modifyA
CanPlace[T, B] place, transfer ²
CanTransform[T, D, B] transform ²

● implemented by the concrete class (hot path) · ○ derived given (wrapper) · ¹ monomorphic alias; the polymorphic trait carries a P suffix (CanModifyP[S, T, A, B], …) · ² no derived given — these two need T-side evidence over the optic's existential X, so they are constructed explicitly: CanPlace.from(myLens).

CanFold.foci returns the plain List[A] of visited foci — the carrier-free counterpart of the raw-optic all extension (which returns List[F[X, A]]).

Coherence: one optic given per (S, A)

A capability is keyed by its type parameters only — which optic backs it is erased. Treat capability evidence like any other typeclass:

Writing your own capability-gated methods

If you take an Optic[…, F] and a typeclass on F in the same signature, put the optic first, in the same using clause:

def render[T, T2, B, F[_, _]](t: T)(using
    o: Optic[T, T2, Json, B, F],  // pins F …
    acc: Accessor[F],             // … before the gate is searched
): Doc = ???

Same-clause parameters resolve left to right, so the optic pins the carrier before Accessor[F] is looked up. A context bound (F[_, _]: Accessor) desugars — since Scala 3.6 — to a clause placed before the explicit one, searching Accessor[F] while F is still free: with more than one instance in scope it fails with a misleading error. (Or skip the issue entirely: take CanGet[T, Json] and let the library do this for you.)

Cats containers as capability evidence

The optic companions derive lawful optics straight from cats typeclasses — as constructors, not givens. A cats container admits several lawful optics at once (the whole-container traversal, a positional lens, a slot traversal, a grate), so no single one can be THE canonical given for its (F[A], A) pair. You pick the optic you mean and declare the given in your own scope — exactly the coherence rule, with the choice where it belongs.

cats class constructor optic capabilities
Traverse[F] Traversal.each Traversal over the elements CanModify + CanFold
Functor[F] Modify.functor write-only Modify (= F.map) CanModify
Foldable[F] Fold[F, A] read-only Fold CanFold
Bitraverse[F] Traversal.first / second / both Traversal over one slot (or every A of an F[A, A]) CanModify + CanFold
Representable[F] Lens.representable(r) lawful Lens at ONE representation point CanGet + CanModify + CanFold
Representable[F] MultiFocus.representable whole-container grate (Distributive with a concrete index) positional rebuilds

Two ways to turn a constructor into evidence — bind it as an optic given (the capability derivations do the rest), or skip optics entirely with a direct capability instance (the Can* traits are SAM-convertible):

import dev.constructive.eo.*
import dev.constructive.eo.optics.{Lens, Traversal}

def bump[S](s: S)(using m: CanModify[S, Int]): S = m.modify(_ + 1)(s)

locally {
  given Traversal[List[Int], List[Int], Int, Int] = Traversal.each
  bump(List(1, 2, 3))
}
// res1: List[Int] = List(2, 3, 4)

locally {
  given CanModify[Vector[Int], Int] = f => _.map(f) // direct instance, no optic
  bump(Vector(1, 2))
}
// res2: Vector[Int] = Vector(2, 3)

// The positional Lens no whole-container bridge can produce — one point of a function:
val atK = Lens.representable[[x] =>> String => x, Int](using summon)("k")
// atK: GetReplaceLens[Function1[String, Int], Function1[String, Int], Int, Int] = dev.constructive.eo.optics.GetReplaceLens@7b628fb5
atK.replace(99)((_: String).length)("k")
// res3: Int = 99

atK.replace(99)((_: String).length)("other") // siblings survive
// res4: Int = 5

Two bridges are left for the reader: ApplicativeCanReverseGetpure is reverseGet, but ship it as a direct capability given, not a Review optic given, or it ambiguates against any element optic on the same container — and the more interesting Comonad/Review dual: extract is a lawful direct CanGet, though extract + map is NOT a lawful Lens on any multi-position container.

When not to use capabilities

In a hot inner loop where the optic is statically known, call the concrete optic directly — the fused members and inline composition are the fastest path, and a capability bound through the derived given pays a per-summon wrapper. On concrete optics the capability methods are the fused members, so ordinary API seams lose nothing. Numbers: capability dispatch benchmarks.