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$15248/0x00007fceaa427040@3c704214
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 known only at the generic Optic[…, F] type (e.g. results of generic composition, or Traversal, whose constructors return anonymous optics) 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.)

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.