karpal-optics
Profunctor optics for Rust: composable, type-safe accessors for nested data structures.
What's inside
Lens
A lens isolates a single field inside a struct, giving you get, set, and
over as first-class values you can store, pass to functions, and compose.
Where plain field access breaks down is when you have nested structs and want to build reusable field modifiers:
use Lens;
let reading = new;
let raw = Sensor ;
// get — extract the focused field
assert_eq!;
// over — apply a transformation (e.g. unit conversion)
let celsius = reading.over;
assert!;
Profunctor transform — first-class field modifiers
transform is where lenses connect to the profunctor hierarchy. Given
any Strong profunctor P<A, B>, it lifts it into P<S, T> — turning
a function on the field into a function on the whole struct.
The result is a Box<dyn Fn(Sensor) -> Sensor> you can store, pass around,
or compose with other functions — something you can't do by writing
sensor.reading = new_value inline:
use Lens;
use FnP;
let reading = new;
// Build a reusable calibration pipeline — it's just a Box<dyn Fn>
let calibrate: = Boxnew;
let calibrate_sensor = reading.;
// Apply it anywhere — the function carries "which field" knowledge
let s1 = Sensor ;
let s2 = Sensor ;
assert!;
assert!;
// You can build multiple transforms from the same lens
let clamp: = Boxnew;
let clamp_sensor = reading.;
let out_of_range = Sensor ;
assert_eq!;
Why not just write a method?
You could write impl Sensor { fn calibrate(self) -> Self { ... } } — and
for a single struct, that's fine. Lenses pay off when:
- You have many structs with similar fields (multiple sensor types, nested configs) and want to reuse the same transformation logic across them.
- You're building a pipeline of field transformations that gets assembled at runtime (e.g., user-configured data processing steps).
- You want to abstract over "which field" — pass a lens as a parameter, letting the caller decide what to focus on.
Lens composition
Chain lenses with .then() to focus multiple levels deep. The result is a
ComposedLens that provides the same get, set, and over interface:
use Lens;
let ceo = new;
let name = new;
let ceo_name = ceo.then;
let co = Company ;
assert_eq!;
let updated = ceo_name.set;
assert_eq!;
For profunctor-level composition, use nested transform calls instead:
outer.transform::<FnP>(inner.transform::<FnP>(pab)).
Prism
A prism focuses on one variant of an enum — the dual of Lens for sum types.
Where Lens uses Strong, Prism uses Choice.
use Prism;
let circle = new;
// preview — extract if variant matches
assert_eq!;
assert_eq!;
// over — modify if matched, pass through otherwise
let doubled = circle.over;
assert_eq!;
let rect = Rectangle;
assert_eq!;
// review — construct the variant
assert_eq!;
Prism also supports transform via Choice, turning a function on the
variant's inner value into a function on the whole enum:
use FnP;
let double: = Boxnew;
let double_circle = circle.;
assert_eq!;
Full optic family
| Optic | Focus | Constraint | Description |
|---|---|---|---|
Iso |
Single, invertible | Profunctor |
Isomorphism between two representations |
Lens |
Single field | Strong |
Read/write access to a product field |
Prism |
Single variant | Choice |
Read/write access to a sum variant |
Getter |
Single, read-only | — | Extract a value without modification |
Review |
Single, write-only | — | Construct a value |
Setter |
Single, write-only | — | Modify without reading |
Traversal |
Multi-focus | Traversing |
Read/write access to multiple targets |
Fold |
Multi-focus, read-only | — | Read multiple targets with Monoid |
All optics support composition and implement the Optic marker trait.
License
AGPL-3.0-or-later