616: Lens Pattern — Composable Getters and Setters
Difficulty: 5 Level: Master A Lens is a first-class, composable getter+setter for a field in a nested struct. Define it once per field; compose them to reach any depth; use `over` to transform without boilerplate.The Problem This Solves
You have a three-level nested struct: `Event` → `Location` → `Coords`. You need to update `lat`. Here's what the code looks like without Lenses:let updated = Event {
location: Location {
coords: Coords {
lat: e.location.coords.lat + 1.0,
lon: e.location.coords.lon, // unchanged, but must be named
},
..e.location.clone() // city, name — unchanged, but must be spread
},
..e.clone() // title, attendees — unchanged, but must be spread
};
That's nine lines to add 1.0 to `lat`. Every field that isn't changing still has to be mentioned — either explicitly or via `..spread`. The signal (add 1.0 to lat) is lost in the noise (rebuilding every level of the struct).
Now multiply this by every field you ever update, every nesting level, every update function in your codebase. You end up with a codebase where struct update expressions are a major source of verbosity and a common place to introduce bugs (forgetting to spread a field, spreading the wrong intermediate struct, etc.).
Lenses abstract over this pattern. You write the struct update expression once — inside the Lens definition — and every call site that needs to update that field calls the Lens instead. This example exists to solve exactly that pain.
The Intuition
A Lens for field `field` of struct `S` is the answer to two questions: 1. "How do I read `field` from an `S`?" → the `get` function 2. "How do I produce a new `S` where `field` is replaced?" → the `set` function Once you have those two functions, everything else follows:- `get`: just call it
- `set`: just call it
- `over`: call `get`, run the function, call `set`
- Composition: the `get` of `A→B` composed with the `get` of `B→C` gives the `get` of `A→C`; same for `set`
// Two simple Lenses
let event_to_location: Lens<Event, Location> = …;
let location_to_coords: Lens<Location, Coords> = …;
let coords_to_lat: Lens<Coords, f64> = …;
// Composed — reaches lat directly from Event
let event_lat = event_to_location
.compose(location_to_coords)
.compose(coords_to_lat);
How It Works in Rust
This example shows two approaches side by side: Approach 1 — Type-parameterized Lens (zero-cost, no allocation):struct Lens<S, A, F: Fn(&S) -> A, G: Fn(A, S) -> S> {
get_fn: F,
set_fn: G,
_phantom: PhantomData<(S, A)>,
}
The closure types `F` and `G` are baked into the type parameters — no `Box`, no heap allocation. The downside: composition produces new types, and storing heterogeneous Lenses requires trait objects.
Approach 2 — SimpleLens with `Box<dyn Fn>` (ergonomic, composable):
struct SimpleLens<S, A> {
getter: Box<dyn Fn(&S) -> A>,
setter: Box<dyn Fn(A, &S) -> S>,
}
Slightly more runtime cost (heap allocation per Lens), but composition is straightforward and the Lens type is uniform regardless of which field it accesses.
Using direct accessor functions (the practical middle ground):
fn get_lat(e: &Event) -> f64 { e.location.coords.lat }
fn set_lat(lat: f64, e: &Event) -> Event {
let mut e2 = e.clone();
e2.location.coords.lat = lat;
e2
}
// Equivalent to over:
let e2 = set_lat(get_lat(&e) + 1.0, &e);
This is what Lens looks like before you generalize it. `get_lat` and `set_lat` are a Lens for `lat`, they're just not first-class yet. The Lens abstraction lets you write this pattern once and use it anywhere.
What This Unlocks
- One-line deep updates: `event_lat.over(|lat| lat + 1.0, &event)` instead of nine lines of struct rebuilding.
- Reusable field accessors: define `coords_to_lat` once; compose it into `event_lat`, `user_home_lat`, `delivery_lat` — any path that ends at a `Coords.lat` reuses the same terminal Lens.
- Type-safe navigation: the types `S` and `A` enforce that Lenses compose correctly — if `Location→Coords` doesn't connect to `Event→Coords`, the compiler rejects it.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Lens composition | Function composition, infix `@@` | `.compose()` method consuming `self` |
| Immutable update | `{ r with field = v }` — no clone needed | `Clone` required at each level |
| Zero-cost Lens | Via functors (Van Laarhoven encoding) | Type-parameterized `Lens<S,A,F,G>` |
| Ergonomic Lens | Simple record of functions | `Box<dyn Fn>` in `SimpleLens<S,A>` |
| Libraries | `Accessor`, `Lens` | `lens`, `lenses` crates |
// Type-safe lens implementation
struct Lens<S, A, F: Fn(&S) -> A, G: Fn(A, S) -> S> {
get_fn: F,
set_fn: G,
_phantom: std::marker::PhantomData<(S,A)>,
}
impl<S: Clone, A: Clone, F: Fn(&S)->A, G: Fn(A,S)->S> Lens<S,A,F,G> {
fn new(get_fn: F, set_fn: G) -> Self {
Lens { get_fn, set_fn, _phantom: std::marker::PhantomData }
}
fn get(&self, s: &S) -> A { (self.get_fn)(s) }
fn set(&self, a: A, s: S) -> S { (self.set_fn)(a, s) }
fn over(&self, f: impl Fn(A)->A, s: S) -> S {
let a = (self.get_fn)(&s);
(self.set_fn)(f(a), s)
}
}
// Simple, practical lens using closures stored in structs
#[derive(Clone)]
struct SimpleLens<S, A> {
getter: Box<dyn Fn(&S) -> A>,
setter: Box<dyn Fn(A, &S) -> S>,
}
impl<S: Clone, A: Clone> SimpleLens<S, A> {
fn new(getter: impl Fn(&S)->A+'static, setter: impl Fn(A,&S)->S+'static) -> Self {
SimpleLens { getter: Box::new(getter), setter: Box::new(setter) }
}
fn get(&self, s: &S) -> A { (self.getter)(s) }
fn set(&self, a: A, s: &S) -> S { (self.setter)(a, s) }
fn over(&self, f: impl Fn(A)->A, s: &S) -> S { self.set(f(self.get(s)), s) }
fn compose<B: Clone+'static>(&self, other: SimpleLens<A,B>) -> SimpleLens<S,B> {
let self_getter = self.getter.clone();
let self_setter = self.setter.clone();
let other_getter = other.getter.clone();
let other_setter = other.setter.clone();
SimpleLens::new(
move |s| other_getter(&self_getter(s)),
move |b, s| {
let a = self_getter(s);
let new_a = other_setter(b, &a);
self_setter(new_a, s)
},
)
}
}
impl<S: Clone, A: Clone> Clone for SimpleLens<S, A> {
fn clone(&self) -> Self {
// We can't clone Box<dyn Fn>; use Rc for sharing
panic!("Use Rc<SimpleLens> for cloning")
}
}
// Practical domain
#[derive(Debug,Clone)]
struct Coords { lat: f64, lon: f64 }
#[derive(Debug,Clone)]
struct Location { name: String, coords: Coords }
#[derive(Debug,Clone)]
struct Event { title: String, location: Location, attendees: u32 }
fn make_event() -> Event {
Event {
title: "Conf".into(),
location: Location { name: "Hall A".into(), coords: Coords { lat:42.3, lon:-71.0 } },
attendees: 100,
}
}
// Use closures directly for the practical example
fn get_lat(e: &Event) -> f64 { e.location.coords.lat }
fn set_lat(lat: f64, e: &Event) -> Event {
let mut e2 = e.clone();
e2.location.coords.lat = lat;
e2
}
fn main() {
let e = make_event();
println!("lat: {:.1}", get_lat(&e));
let e2 = set_lat(get_lat(&e) + 1.0, &e);
println!("new lat: {:.1}", get_lat(&e2));
// Nested update without lens (boilerplate)
let e3 = Event {
location: Location {
coords: Coords { lat: e.location.coords.lat, lon: e.location.coords.lon + 5.0 },
..e.location.clone()
},
..e.clone()
};
println!("new lon: {:.1}", e3.location.coords.lon);
// Update attendees
let e4 = Event { attendees: e.attendees * 2, ..e.clone() };
println!("attendees*2: {}", e4.attendees);
}
#[cfg(test)]
mod tests {
use super::*;
#[test] fn test_get_lat() { let e=make_event(); assert_eq!(get_lat(&e), 42.3); }
#[test] fn test_set_lat() { let e=make_event(); let e2=set_lat(99.0,&e); assert_eq!(get_lat(&e2), 99.0); }
}
(* Lenses in OCaml — composable record accessors *)
type ('s,'a) lens = { get: 's -> 'a; set: 'a -> 's -> 's }
let ( |> ) x f = f x
let lens_compose l1 l2 = {
get = (fun s -> l2.get (l1.get s));
set = (fun a s -> l1.set (l2.set a (l1.get s)) s);
}
let over l f s = l.set (f (l.get s)) s
(* Domain *)
type coords = { lat: float; lon: float }
type location = { name: string; coords: coords }
type event = { title: string; location: location; attendees: int }
let title_l = { get=(fun e->e.title); set=(fun v e->{e with title=v}) }
let location_l = { get=(fun e->e.location); set=(fun v e->{e with location=v}) }
let coords_l = { get=(fun l->l.coords); set=(fun v l->{l with coords=v}) }
let lat_l = { get=(fun c->c.lat); set=(fun v c->{c with lat=v}) }
let attendees_l = { get=(fun e->e.attendees); set=(fun v e->{e with attendees=v}) }
let event_lat = lens_compose (lens_compose location_l coords_l) lat_l
let () =
let e = { title="Conf"; location={ name="Hall A"; coords={ lat=42.3;lon= -71.0 } }; attendees=100 } in
Printf.printf "lat: %.1f\n" (event_lat.get e);
let e2 = over event_lat (fun lat -> lat +. 1.0) e in
Printf.printf "new lat: %.1f\n" (event_lat.get e2);
let e3 = over attendees_l (fun n -> n*2) e in
Printf.printf "attendees*2: %d\n" (attendees_l.get e3)