🦀 Functional Rust

615: Optics Intro — Lenses, Prisms, and the Hierarchy

Difficulty: 5 Level: Master Optics are composable accessors for data structures. A Lens accesses a field that always exists; a Prism accesses a variant that might exist; together they form the foundation of a larger composable hierarchy.

The Problem This Solves

You write code that reads and updates nested data structures. At every level, you choose between two bad options: write verbose nested struct-update expressions (`Event { location: Location { coords: Coords { lat: ..., ..e.location.coords }, ..e.location }, ..e }`) or write a proliferation of one-off helper functions that don't compose. The deeper problem is that data access in most languages isn't compositional. You can't combine "get the city of an address" with "get the address of a person" to automatically get "get the city of a person's address" — you have to write that third function explicitly. And when you have many fields, many nesting levels, and both structs (product types) and enums (sum types), the explosion of accessor functions becomes unmanageable. Optics solve this at the category level: they provide a unified abstraction where accessors compose automatically, work for both structs and enums, and guarantee correct behaviour through algebraic laws. This example exists to solve exactly that pain.

The Intuition

The hierarchy — each level is a more powerful optic:
OpticWorks onGet returnsSet behaviour
LensProduct types (structs)`A` — always succeedsAlways replaces
PrismSum types (enums)`Option<A>` — may failOnly if variant matches
TraversalCollections / multiple targetsIterator of `A`Applies to each element
Think of them as drill bits of increasing flexibility: Lenses are for struct fields. Every `Person` has an `age`, so `age_lens.view(&person)` always returns a `u32`. Prisms are for enum variants. Not every `Json` is a `JString`, so `jstring_prism.preview(&json)` returns `Option<String>`. The key insight: a Lens composed with a Prism gives you a Traversal — you get a "get into a field that might exist", which is exactly a partial accessor. The composition rules are what make optics powerful: mix and match, and the type system tells you what you get.

How It Works in Rust

Lens — struct field access:
struct Lens<S, A> {
 get: fn(&S) -> A,
 set: fn(A, S) -> S,
}

let name_lens: Lens<Person, String> = Lens::new(
 |p| p.name.clone(),
 |v, mut p| { p.name = v; p },
);

let city_lens: Lens<Address, String> = Lens::new(
 |a| a.city.clone(),
 |v, mut a| { a.city = v; a },
);

// Get city via two lenses
let addr = addr_lens.view(&person);
let city = city_lens.view(&addr);
Note: `fn` pointer versions (as in this example) avoid heap allocation but can't close over values. For composition that closes over state, use `Box<dyn Fn>` (shown in examples 201–205). Prism — enum variant access:
struct Prism<S, A> {
 preview: fn(&S) -> Option<A>,   // may return None
 review:  fn(A) -> S,            // always succeeds
}

let some_prism: Prism<Option<i32>, i32> = Prism::new(
 |o| *o,           // Option<i32> already is Option<i32>
 |a| Some(a),
);

some_prism.preview(&Some(42))  // Some(42)
some_prism.preview(&None)      // None
some_prism.review(7)           // Some(7)
Using `over` for transformation:
let p2 = age_lens.over(|a| a + 1, person.clone());
// person is immutable; p2 has age incremented

What This Unlocks

Key Differences

ConceptOCamlRust
Lens typeRecord `{ get: 's -> 'a; set: 'a -> 's -> 's }``struct Lens<S,A>` with `fn` pointers or `Box<dyn Fn>`
Prism typeRecord `{ preview: 's -> 'a option; review: 'a -> 's }``struct Prism<S,A>` with same signatures
Traversal`'s -> 'a list` + `'a list -> 's -> 's``.iter().map()` + collect
CompositionFunction composition (infix `@@`)`.compose()` method or manual closure chaining
IsoBijection pair`fn to()` / `fn from()` — bidirectional conversion
//! # Optics Introduction
//! Composable accessors for nested data.

pub struct Lens<S, A> {
    pub get: Box<dyn Fn(&S) -> A>,
    pub set: Box<dyn Fn(&S, A) -> S>,
}

impl<S: Clone + 'static, A: Clone + 'static> Lens<S, A> {
    pub fn new(get: impl Fn(&S) -> A + 'static, set: impl Fn(&S, A) -> S + 'static) -> Self {
        Lens { get: Box::new(get), set: Box::new(set) }
    }
    pub fn view(&self, s: &S) -> A { (self.get)(s) }
    pub fn over(&self, s: &S, f: impl Fn(A) -> A) -> S {
        let a = (self.get)(s);
        (self.set)(s, f(a))
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct Person { pub name: String, pub age: u32 }

pub fn name_lens() -> Lens<Person, String> {
    Lens::new(|p: &Person| p.name.clone(), |p: &Person, n| Person { name: n, ..p.clone() })
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test] fn test_lens() {
        let p = Person { name: "Alice".into(), age: 30 };
        let lens = name_lens();
        assert_eq!(lens.view(&p), "Alice");
        let p2 = lens.over(&p, |n| n.to_uppercase());
        assert_eq!(p2.name, "ALICE");
    }
}
(* Optics intro in OCaml *)
type address = { street: string; city: string; zip: string }
type person  = { name: string; age: int; address: address }

(* Lens: pair of (get, set) *)
type ('s,'a) lens = {
  get: 's -> 'a;
  set: 'a -> 's -> 's;
}

let name_lens    = { get=(fun p->p.name);    set=(fun v p->{p with name=v}) }
let age_lens     = { get=(fun p->p.age);     set=(fun v p->{p with age=v}) }
let address_lens = { get=(fun p->p.address); set=(fun v p->{p with address=v}) }
let city_lens    = { get=(fun a->a.city);    set=(fun v a->{a with city=v}) }

let compose outer inner = {
  get = (fun s -> inner.get (outer.get s));
  set = (fun a s -> outer.set (inner.set a (outer.get s)) s);
}

let person_city = compose address_lens city_lens

let modify lens f s = lens.set (f (lens.get s)) s

let () =
  let p = { name="Alice"; age=30; address={ street="1 Main St"; city="Boston"; zip="02101" } } in
  Printf.printf "name: %s\n" (name_lens.get p);
  Printf.printf "city: %s\n" (person_city.get p);
  let p2 = person_city.set "Cambridge" p in
  Printf.printf "new city: %s\n" (person_city.get p2);
  let p3 = modify age_lens (fun a -> a+1) p in
  Printf.printf "age+1: %d\n" (age_lens.get p3)