ExamplesBy LevelBy TopicLearning Paths
211 Advanced

Optics Hierarchy

Functional Programming

Tutorial

The Problem

Iso, Lens, Prism, AffineTraversal, and Traversal form a hierarchy: every Iso is a Lens and a Prism; every Lens and Prism is an AffineTraversal; every AffineTraversal is a Traversal. This hierarchy means you can write generic algorithms at the most general level (Traversal) that automatically work for any more specific optic. Understanding the hierarchy prevents reimplementing the same operation for each optic type.

🎯 Learning Outcomes

  • • Understand the full optics hierarchy: Iso ⊂ Lens ⊂ AffineTraversal ⊂ Traversal, Iso ⊂ Prism ⊂ AffineTraversal
  • • Learn how upcasting (narrowing capabilities) works in the hierarchy
  • • See why generic algorithms written at the Traversal level work for any optic
  • • Appreciate the hierarchy as a mathematical lattice of abstraction levels
  • Code Example

    pub struct Lens<S, A> {
        get_fn: Box<dyn Fn(&S) -> A>,
        set_fn: Box<dyn Fn(A, &S) -> S>,
    }
    
    impl<S: Clone + 'static, A: Clone + 'static> Lens<S, A> {
        /// Every Lens is a Traversal.
        pub fn as_traversal(self) -> Traversal<S, A> {
            use std::rc::Rc;
            let get_fn = Rc::new(self.get_fn);
            let get_fn2 = Rc::clone(&get_fn);
            let set_fn = self.set_fn;
            Traversal::new(
                move |f, s| { let a = get_fn(s); set_fn(f(&a), s) },
                move |s| vec![get_fn2(s)],
            )
        }
    }

    Key Differences

  • Subtyping model: OCaml uses module inclusion; Rust uses explicit conversion (as_traversal) — neither has native covariant subtyping for struct types.
  • Profunctor encoding: Haskell's Van Laarhoven (example 212) and profunctor encodings represent the hierarchy implicitly via type class bounds; Rust and OCaml use explicit struct types.
  • Composition safety: The hierarchy ensures composed optics are always well-typed — composing a lens and a traversal gives a traversal, never an invalid optic.
  • Generic functions: Functions parameterized by T: AsTraversal work for all optic types; this requires trait bounds in Rust and module type constraints in OCaml.
  • OCaml Approach

    OCaml's optics library uses module types to express the hierarchy:

    module type TRAVERSAL = sig ... end
    module type LENS = sig ... include TRAVERSAL ... end
    module type ISO = sig ... include LENS ... include PRISM ... end
    

    Module inclusion mirrors the subtyping relationship. Haskell's profunctor optics represent the hierarchy via type class constraints — a Lens is any Strong + Profunctor, a Prism is any Choice + Profunctor, etc.

    Full Source

    #![allow(clippy::all)]
    // Example 211: Optics Hierarchy — Iso ⊂ Lens ⊂ Traversal, Iso ⊂ Prism ⊂ Traversal
    //
    // The complete hierarchy, from most specific to most general:
    //
    //         Iso           ← lossless bijection; exactly 1 focus
    //        /   \
    //      Lens  Prism      ← Lens: exactly 1 focus (products)
    //        \   /          ← Prism: 0-or-1 focus (sums)
    //       Traversal       ← 0-to-many focuses (most general)
    //
    // Every Iso is a Lens and a Prism.
    // Every Lens is a Traversal. Every Prism is a Traversal.
    //
    // Generic functions written at the Traversal level accept any optic via upcasting,
    // eliminating duplicated code across optic types.
    
    // ---------------------------------------------------------------------------
    // Approach 1: Struct-based optics with explicit `as_*` upcast methods
    // ---------------------------------------------------------------------------
    
    type GetFn<S, A> = Box<dyn Fn(&S) -> A>;
    type SetFn<S, A> = Box<dyn Fn(A, &S) -> S>;
    type PreviewFn<S, A> = Box<dyn Fn(&S) -> Option<A>>;
    type ReviewFn<S, A> = Box<dyn Fn(A) -> S>;
    type OverFn<S, A> = Box<dyn Fn(&dyn Fn(&A) -> A, &S) -> S>;
    type ToListFn<S, A> = Box<dyn Fn(&S) -> Vec<A>>;
    
    // -- Traversal (most general: 0-to-many focuses) ----------------------------
    
    /// A Traversal focuses on zero or more values of type `A` inside `S`.
    /// Every other optic can be upcast to a Traversal.
    ///
    /// OCaml equivalent:
    /// ```ocaml
    /// type ('s, 'a) traversal = { over : ('a -> 'a) -> 's -> 's; to_list : 's -> 'a list }
    /// ```
    pub struct Traversal<S, A> {
        over_fn: OverFn<S, A>,
        to_list_fn: ToListFn<S, A>,
    }
    
    impl<S: 'static, A: 'static> Traversal<S, A> {
        pub fn new(
            over: impl Fn(&dyn Fn(&A) -> A, &S) -> S + 'static,
            to_list: impl Fn(&S) -> Vec<A> + 'static,
        ) -> Self {
            Traversal {
                over_fn: Box::new(over),
                to_list_fn: Box::new(to_list),
            }
        }
    
        /// Apply `f` to every focused value, returning the updated structure.
        pub fn over(&self, f: impl Fn(&A) -> A, s: &S) -> S {
            (self.over_fn)(&f, s)
        }
    
        /// Collect all focused values into a `Vec`.
        pub fn collect_all(&self, s: &S) -> Vec<A> {
            (self.to_list_fn)(s)
        }
    
        /// Count how many values are focused.
        pub fn length_of(&self, s: &S) -> usize {
            self.collect_all(s).len()
        }
    }
    
    // -- Lens (exactly 1 focus: product types) ----------------------------------
    
    /// A Lens focuses on exactly one value of type `A` inside a product `S`.
    ///
    /// OCaml equivalent:
    /// ```ocaml
    /// type ('s, 'a) lens = { get : 's -> 'a; set : 'a -> 's -> 's }
    /// ```
    pub struct Lens<S, A> {
        get_fn: GetFn<S, A>,
        set_fn: SetFn<S, A>,
    }
    
    impl<S: Clone + 'static, A: Clone + 'static> Lens<S, A> {
        pub fn new(get: impl Fn(&S) -> A + 'static, set: impl Fn(A, &S) -> S + 'static) -> Self {
            Lens {
                get_fn: Box::new(get),
                set_fn: Box::new(set),
            }
        }
    
        pub fn get(&self, s: &S) -> A {
            (self.get_fn)(s)
        }
    
        pub fn set(&self, a: A, s: &S) -> S {
            (self.set_fn)(a, s)
        }
    
        pub fn over(&self, f: impl FnOnce(A) -> A, s: &S) -> S {
            self.set(f(self.get(s)), s)
        }
    
        /// Every Lens is a Traversal.
        ///
        /// The single focus becomes a singleton list; `over` applies to exactly
        /// one value. This is the "upcast" that lets a Lens be used wherever a
        /// Traversal is expected.
        pub fn as_traversal(self) -> Traversal<S, A> {
            use std::rc::Rc;
            // Share get_fn between the two closures via Rc — same pattern as
            // lens composition in example 204.
            let get_fn = Rc::new(self.get_fn);
            let get_fn2 = Rc::clone(&get_fn);
            let set_fn = self.set_fn;
            Traversal::new(
                move |f, s| {
                    let a = get_fn(s);
                    set_fn(f(&a), s)
                },
                move |s| vec![get_fn2(s)],
            )
        }
    }
    
    // -- Prism (0-or-1 focus: sum types) ----------------------------------------
    
    /// A Prism focuses on 0 or 1 values of type `A` inside a sum `S`.
    ///
    /// OCaml equivalent:
    /// ```ocaml
    /// type ('s, 'a) prism = { preview : 's -> 'a option; review : 'a -> 's }
    /// ```
    pub struct Prism<S, A> {
        preview_fn: PreviewFn<S, A>,
        review_fn: ReviewFn<S, A>,
    }
    
    impl<S: Clone + 'static, A: Clone + 'static> Prism<S, A> {
        pub fn new(
            preview: impl Fn(&S) -> Option<A> + 'static,
            review: impl Fn(A) -> S + 'static,
        ) -> Self {
            Prism {
                preview_fn: Box::new(preview),
                review_fn: Box::new(review),
            }
        }
    
        pub fn preview(&self, s: &S) -> Option<A> {
            (self.preview_fn)(s)
        }
    
        pub fn review(&self, a: A) -> S {
            (self.review_fn)(a)
        }
    
        /// Every Prism is a Traversal.
        ///
        /// When `preview` succeeds there is exactly one focus; otherwise zero.
        /// `over` is a no-op when the variant doesn't match — structural identity
        /// preserved via `Clone`.
        pub fn as_traversal(self) -> Traversal<S, A> {
            use std::rc::Rc;
            let preview_fn = Rc::new(self.preview_fn);
            let preview_fn2 = Rc::clone(&preview_fn);
            let review_fn = self.review_fn;
            Traversal::new(
                move |f, s| match preview_fn(s) {
                    Some(a) => review_fn(f(&a)),
                    None => s.clone(),
                },
                move |s| match preview_fn2(s) {
                    Some(a) => vec![a],
                    None => vec![],
                },
            )
        }
    }
    
    // -- Iso (lossless bijection) -----------------------------------------------
    
    /// An Iso is a lossless two-way bijection between `S` and `A`.
    /// It is simultaneously a Lens and a Prism — the most specific optic.
    ///
    /// OCaml equivalent:
    /// ```ocaml
    /// type ('s, 'a) iso = { get : 's -> 'a; reverse_get : 'a -> 's }
    /// ```
    pub struct Iso<S, A> {
        get_fn: GetFn<S, A>,
        reverse_get_fn: ReviewFn<S, A>,
    }
    
    impl<S: Clone + 'static, A: Clone + 'static> Iso<S, A> {
        pub fn new(get: impl Fn(&S) -> A + 'static, reverse_get: impl Fn(A) -> S + 'static) -> Self {
            Iso {
                get_fn: Box::new(get),
                reverse_get_fn: Box::new(reverse_get),
            }
        }
    
        pub fn get(&self, s: &S) -> A {
            (self.get_fn)(s)
        }
    
        pub fn reverse_get(&self, a: A) -> S {
            (self.reverse_get_fn)(a)
        }
    
        /// An Iso is a Lens.
        ///
        /// `set(a, _s)` discards `_s` entirely — valid *only* for a lossless Iso,
        /// where `a` alone determines the full structure via `reverse_get`.
        /// A regular Lens cannot do this because `S` may carry other fields.
        pub fn as_lens(self) -> Lens<S, A> {
            let rev = self.reverse_get_fn;
            Lens::new(self.get_fn, move |a, _| rev(a))
        }
    
        /// An Iso is a Prism.
        ///
        /// `preview` always succeeds (wraps in `Some`) because the bijection is
        /// total — there is no "wrong variant". `review` = `reverse_get`.
        pub fn as_prism(self) -> Prism<S, A> {
            let get_fn = self.get_fn;
            Prism::new(move |s| Some(get_fn(s)), self.reverse_get_fn)
        }
    
        /// An Iso is a Traversal (via its Lens representation).
        pub fn as_traversal(self) -> Traversal<S, A> {
            self.as_lens().as_traversal()
        }
    }
    
    // ---------------------------------------------------------------------------
    // Domain model
    // ---------------------------------------------------------------------------
    
    /// Temperature in Celsius — demonstrates Iso (`Celsius` ↔ `f64` is lossless).
    #[derive(Debug, Clone, PartialEq)]
    pub struct Celsius(pub f64);
    
    /// A 2D point — demonstrates Lens (focus on one field of a product).
    #[derive(Debug, Clone, PartialEq)]
    pub struct Point {
        pub x: f64,
        pub y: f64,
    }
    
    /// A geometric shape — demonstrates Prism (focus on one variant of a sum).
    #[derive(Debug, Clone, PartialEq)]
    pub enum Shape {
        Circle { radius: f64 },
        Rect { width: f64, height: f64 },
    }
    
    /// Iso: `Celsius` ↔ `f64` — lossless because `Celsius(x)` and `x` are the same value.
    pub fn celsius_iso() -> Iso<Celsius, f64> {
        Iso::new(|c: &Celsius| c.0, Celsius)
    }
    
    /// Lens: focus on `Point::x` — `y` is preserved unchanged on every `set`.
    pub fn point_x_lens() -> Lens<Point, f64> {
        Lens::new(|p: &Point| p.x, |x, p: &Point| Point { x, y: p.y })
    }
    
    /// Prism: focus on `Shape::Circle`'s radius; `Shape::Rect` is a miss.
    pub fn circle_radius_prism() -> Prism<Shape, f64> {
        Prism::new(
            |s: &Shape| match s {
                Shape::Circle { radius } => Some(*radius),
                Shape::Rect { .. } => None,
            },
            |r| Shape::Circle { radius: r },
        )
    }
    
    // ---------------------------------------------------------------------------
    // Approach 2: Trait-based hierarchy (zero-cost compile-time dispatch)
    // ---------------------------------------------------------------------------
    //
    // Each trait is a supertype of the next: IsoOptic ⊂ LensOptic ⊂ OpticBase
    //                                         IsoOptic ⊂ PrismOptic ⊂ OpticBase
    //
    // Generic functions written against `OpticBase` work for any optic type.
    
    /// Base trait for all optics. Provides `collect` (the universal read) and
    /// `over_t` (the universal write). Generic code accepting `&dyn OpticBase`
    /// works for Lens, Prism, and Iso without knowing which one it has.
    pub trait OpticBase<S: Clone, A: Clone> {
        fn collect(&self, s: &S) -> Vec<A>;
        fn over_t(&self, f: &dyn Fn(&A) -> A, s: &S) -> S;
    
        fn length(&self, s: &S) -> usize {
            self.collect(s).len()
        }
    }
    
    /// A Lens optic: always exactly one focus. Extends `OpticBase` with `view`
    /// (always-succeeding get) and `set`.
    pub trait LensOptic<S: Clone, A: Clone>: OpticBase<S, A> {
        fn view(&self, s: &S) -> A;
        fn set(&self, a: A, s: &S) -> S;
    }
    
    /// A Prism optic: 0-or-1 focus. Extends `OpticBase` with `preview` and `review`.
    pub trait PrismOptic<S: Clone, A: Clone>: OpticBase<S, A> {
        fn preview(&self, s: &S) -> Option<A>;
        fn review(&self, a: A) -> S;
    }
    
    /// An Iso optic: both a Lens and a Prism. `reverse_get` makes it explicit that
    /// the mapping is lossless in both directions.
    pub trait IsoOptic<S: Clone, A: Clone>: LensOptic<S, A> + PrismOptic<S, A> {
        fn reverse_get(&self, a: A) -> S;
    }
    
    // -- Concrete: PointXLens ---------------------------------------------------
    
    /// Marker type for the `Point::x` lens (zero-cost, no heap allocation).
    pub struct PointXLens;
    
    impl OpticBase<Point, f64> for PointXLens {
        fn collect(&self, s: &Point) -> Vec<f64> {
            vec![s.x]
        }
    
        fn over_t(&self, f: &dyn Fn(&f64) -> f64, s: &Point) -> Point {
            Point { x: f(&s.x), y: s.y }
        }
    }
    
    impl LensOptic<Point, f64> for PointXLens {
        fn view(&self, s: &Point) -> f64 {
            s.x
        }
    
        fn set(&self, a: f64, s: &Point) -> Point {
            Point { x: a, y: s.y }
        }
    }
    
    // -- Concrete: CircleRadiusPrism --------------------------------------------
    
    /// Marker type for the `Shape::Circle` radius prism.
    pub struct CircleRadiusPrism;
    
    impl OpticBase<Shape, f64> for CircleRadiusPrism {
        fn collect(&self, s: &Shape) -> Vec<f64> {
            match s {
                Shape::Circle { radius } => vec![*radius],
                Shape::Rect { .. } => vec![],
            }
        }
    
        fn over_t(&self, f: &dyn Fn(&f64) -> f64, s: &Shape) -> Shape {
            match s {
                Shape::Circle { radius } => Shape::Circle { radius: f(radius) },
                Shape::Rect { .. } => s.clone(),
            }
        }
    }
    
    impl PrismOptic<Shape, f64> for CircleRadiusPrism {
        fn preview(&self, s: &Shape) -> Option<f64> {
            match s {
                Shape::Circle { radius } => Some(*radius),
                Shape::Rect { .. } => None,
            }
        }
    
        fn review(&self, a: f64) -> Shape {
            Shape::Circle { radius: a }
        }
    }
    
    // -- Concrete: CelsiusIso ---------------------------------------------------
    
    /// Marker type for the `Celsius` ↔ `f64` iso.
    /// Implements `OpticBase`, `LensOptic`, `PrismOptic`, and `IsoOptic`,
    /// demonstrating that an Iso satisfies the full hierarchy.
    pub struct CelsiusIso;
    
    impl OpticBase<Celsius, f64> for CelsiusIso {
        fn collect(&self, s: &Celsius) -> Vec<f64> {
            vec![s.0]
        }
    
        fn over_t(&self, f: &dyn Fn(&f64) -> f64, s: &Celsius) -> Celsius {
            Celsius(f(&s.0))
        }
    }
    
    impl LensOptic<Celsius, f64> for CelsiusIso {
        fn view(&self, s: &Celsius) -> f64 {
            s.0
        }
    
        /// `set` discards the old `Celsius` — valid because an Iso is lossless
        /// and `a` completely determines the result.
        fn set(&self, a: f64, _s: &Celsius) -> Celsius {
            Celsius(a)
        }
    }
    
    impl PrismOptic<Celsius, f64> for CelsiusIso {
        /// `preview` always returns `Some` for an Iso — no variant can be absent.
        fn preview(&self, s: &Celsius) -> Option<f64> {
            Some(s.0)
        }
    
        fn review(&self, a: f64) -> Celsius {
            Celsius(a)
        }
    }
    
    impl IsoOptic<Celsius, f64> for CelsiusIso {
        fn reverse_get(&self, a: f64) -> Celsius {
            Celsius(a)
        }
    }
    
    // ---------------------------------------------------------------------------
    // Generic functions that accept any optic via `OpticBase`
    // ---------------------------------------------------------------------------
    
    /// Count the number of focuses any optic has on `s`.
    /// - Lens: always 1
    /// - Prism: 0 (miss) or 1 (hit)
    /// - Iso: always 1
    pub fn count_focuses<S: Clone, A: Clone>(optic: &dyn OpticBase<S, A>, s: &S) -> usize {
        optic.length(s)
    }
    
    /// Return the first focused value, if any. Uniform across all optic types.
    pub fn first_focus<S: Clone, A: Clone>(optic: &dyn OpticBase<S, A>, s: &S) -> Option<A> {
        optic.collect(s).into_iter().next()
    }
    
    // ---------------------------------------------------------------------------
    // Tests
    // ---------------------------------------------------------------------------
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        // -- Approach 1: struct-based upcasting -----------------------------------
    
        #[test]
        fn test_iso_as_lens_get_and_set() {
            let lens = celsius_iso().as_lens();
            let c = Celsius(100.0);
            assert_eq!(lens.get(&c), 100.0);
            // set discards the original Celsius — valid because the Iso is lossless
            assert_eq!(lens.set(37.0, &c), Celsius(37.0));
        }
    
        #[test]
        fn test_iso_as_prism_preview_always_succeeds() {
            let prism = celsius_iso().as_prism();
            // An Iso's preview always returns Some — no variant can be absent
            assert_eq!(prism.preview(&Celsius(25.5)), Some(25.5));
            assert_eq!(prism.preview(&Celsius(-10.0)), Some(-10.0));
        }
    
        #[test]
        fn test_iso_as_prism_review_roundtrips() {
            let prism = celsius_iso().as_prism();
            assert_eq!(prism.review(100.0), Celsius(100.0));
            assert_eq!(prism.review(0.0), Celsius(0.0));
        }
    
        #[test]
        fn test_iso_as_traversal_collect_gives_singleton() {
            let trav = celsius_iso().as_traversal();
            // An Iso always has exactly 1 focus — collect yields a singleton
            assert_eq!(trav.collect_all(&Celsius(42.0)), vec![42.0]);
            assert_eq!(trav.length_of(&Celsius(42.0)), 1);
        }
    
        #[test]
        fn test_iso_as_traversal_over_transforms_value() {
            let trav = celsius_iso().as_traversal();
            // Celsius → Kelvin offset: add 273.15
            let result = trav.over(|f| f + 273.15, &Celsius(100.0));
            assert!((result.0 - 373.15).abs() < 1e-10);
        }
    
        #[test]
        fn test_lens_as_traversal_collect_gives_singleton() {
            let trav = point_x_lens().as_traversal();
            let p = Point { x: 3.0, y: 4.0 };
            // A Lens always has exactly 1 focus — same as Iso at the Traversal level
            assert_eq!(trav.collect_all(&p), vec![3.0]);
            assert_eq!(trav.length_of(&p), 1);
        }
    
        #[test]
        fn test_lens_as_traversal_over_modifies_only_x() {
            let trav = point_x_lens().as_traversal();
            let p = Point { x: 2.0, y: 5.0 };
            let result = trav.over(|x| x * 10.0, &p);
            // x is scaled; y is preserved — Traversal has the same semantics as Lens
            assert_eq!(result, Point { x: 20.0, y: 5.0 });
        }
    
        #[test]
        fn test_prism_as_traversal_collect_hit() {
            let trav = circle_radius_prism().as_traversal();
            // Circle matches → exactly 1 focus
            assert_eq!(trav.collect_all(&Shape::Circle { radius: 7.0 }), vec![7.0]);
            assert_eq!(trav.length_of(&Shape::Circle { radius: 7.0 }), 1);
        }
    
        #[test]
        fn test_prism_as_traversal_collect_miss() {
            let trav = circle_radius_prism().as_traversal();
            // Rect doesn't match → 0 focuses
            let rect = Shape::Rect {
                width: 4.0,
                height: 3.0,
            };
            assert_eq!(trav.collect_all(&rect), vec![]);
            assert_eq!(trav.length_of(&rect), 0);
        }
    
        #[test]
        fn test_prism_as_traversal_over_hit_scales_radius() {
            let trav = circle_radius_prism().as_traversal();
            let result = trav.over(|r| r * 2.0, &Shape::Circle { radius: 5.0 });
            assert_eq!(result, Shape::Circle { radius: 10.0 });
        }
    
        #[test]
        fn test_prism_as_traversal_over_miss_is_noop() {
            let trav = circle_radius_prism().as_traversal();
            let rect = Shape::Rect {
                width: 4.0,
                height: 3.0,
            };
            // over on a miss returns a structural clone — no modification
            assert_eq!(trav.over(|r| r * 2.0, &rect), rect);
        }
    
        // -- Approach 2: trait-based hierarchy ------------------------------------
    
        #[test]
        fn test_trait_lens_count_is_always_one() {
            let lens = PointXLens;
            let p = Point { x: 1.0, y: 2.0 };
            assert_eq!(count_focuses(&lens, &p), 1);
        }
    
        #[test]
        fn test_trait_prism_count_hit_and_miss() {
            let prism = CircleRadiusPrism;
            assert_eq!(count_focuses(&prism, &Shape::Circle { radius: 3.0 }), 1);
            assert_eq!(
                count_focuses(
                    &prism,
                    &Shape::Rect {
                        width: 1.0,
                        height: 1.0
                    }
                ),
                0
            );
        }
    
        #[test]
        fn test_trait_iso_count_is_always_one() {
            let iso = CelsiusIso;
            // Iso is both Lens and Prism, so count is always 1
            assert_eq!(count_focuses(&iso, &Celsius(0.0)), 1);
            assert_eq!(count_focuses(&iso, &Celsius(-273.15)), 1);
        }
    
        #[test]
        fn test_trait_lens_view_set_and_over_t() {
            let lens = PointXLens;
            let p = Point { x: 1.5, y: 9.0 };
            assert_eq!(lens.view(&p), 1.5);
            assert_eq!(lens.set(42.0, &p), Point { x: 42.0, y: 9.0 });
            assert_eq!(lens.over_t(&|x| x + 1.0, &p), Point { x: 2.5, y: 9.0 });
        }
    
        #[test]
        fn test_trait_prism_preview_review_and_over_t() {
            let prism = CircleRadiusPrism;
            let circle = Shape::Circle { radius: 4.0 };
            let rect = Shape::Rect {
                width: 2.0,
                height: 3.0,
            };
            assert_eq!(prism.preview(&circle), Some(4.0));
            assert_eq!(prism.preview(&rect), None);
            assert_eq!(prism.review(8.0), Shape::Circle { radius: 8.0 });
            // over_t on a miss returns the original structure unchanged
            assert_eq!(prism.over_t(&|r| r * 2.0, &rect), rect);
        }
    
        #[test]
        fn test_trait_iso_acts_as_both_lens_and_prism() {
            let iso = CelsiusIso;
            let c = Celsius(20.0);
            // As LensOptic
            assert_eq!(iso.view(&c), 20.0);
            assert_eq!(iso.set(37.0, &c), Celsius(37.0));
            // As PrismOptic — preview always Some for an Iso
            assert_eq!(iso.preview(&c), Some(20.0));
            assert_eq!(iso.review(100.0), Celsius(100.0));
            // As IsoOptic
            assert_eq!(iso.reverse_get(0.0), Celsius(0.0));
        }
    
        #[test]
        fn test_first_focus_generic_across_all_optic_types() {
            let lens = PointXLens;
            let prism = CircleRadiusPrism;
            let iso = CelsiusIso;
            // Lens: always Some
            assert_eq!(first_focus(&lens, &Point { x: 7.0, y: 0.0 }), Some(7.0));
            // Prism: Some on hit, None on miss
            assert_eq!(
                first_focus(&prism, &Shape::Circle { radius: 3.0 }),
                Some(3.0)
            );
            assert_eq!(
                first_focus(
                    &prism,
                    &Shape::Rect {
                        width: 1.0,
                        height: 2.0
                    }
                ),
                None
            );
            // Iso: always Some
            assert_eq!(first_focus(&iso, &Celsius(99.0)), Some(99.0));
        }
    
        #[test]
        fn test_hierarchy_upcasting_same_semantics_lens_and_traversal() {
            // Using a Lens directly and via Traversal upcast must give the same result
            let lens = point_x_lens();
            let trav = point_x_lens().as_traversal();
            let p = Point { x: 5.0, y: 3.0 };
            let via_lens = lens.over(|x| x + 1.0, &p);
            let via_trav = trav.over(|x| x + 1.0, &p);
            assert_eq!(via_lens, via_trav);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        // -- Approach 1: struct-based upcasting -----------------------------------
    
        #[test]
        fn test_iso_as_lens_get_and_set() {
            let lens = celsius_iso().as_lens();
            let c = Celsius(100.0);
            assert_eq!(lens.get(&c), 100.0);
            // set discards the original Celsius — valid because the Iso is lossless
            assert_eq!(lens.set(37.0, &c), Celsius(37.0));
        }
    
        #[test]
        fn test_iso_as_prism_preview_always_succeeds() {
            let prism = celsius_iso().as_prism();
            // An Iso's preview always returns Some — no variant can be absent
            assert_eq!(prism.preview(&Celsius(25.5)), Some(25.5));
            assert_eq!(prism.preview(&Celsius(-10.0)), Some(-10.0));
        }
    
        #[test]
        fn test_iso_as_prism_review_roundtrips() {
            let prism = celsius_iso().as_prism();
            assert_eq!(prism.review(100.0), Celsius(100.0));
            assert_eq!(prism.review(0.0), Celsius(0.0));
        }
    
        #[test]
        fn test_iso_as_traversal_collect_gives_singleton() {
            let trav = celsius_iso().as_traversal();
            // An Iso always has exactly 1 focus — collect yields a singleton
            assert_eq!(trav.collect_all(&Celsius(42.0)), vec![42.0]);
            assert_eq!(trav.length_of(&Celsius(42.0)), 1);
        }
    
        #[test]
        fn test_iso_as_traversal_over_transforms_value() {
            let trav = celsius_iso().as_traversal();
            // Celsius → Kelvin offset: add 273.15
            let result = trav.over(|f| f + 273.15, &Celsius(100.0));
            assert!((result.0 - 373.15).abs() < 1e-10);
        }
    
        #[test]
        fn test_lens_as_traversal_collect_gives_singleton() {
            let trav = point_x_lens().as_traversal();
            let p = Point { x: 3.0, y: 4.0 };
            // A Lens always has exactly 1 focus — same as Iso at the Traversal level
            assert_eq!(trav.collect_all(&p), vec![3.0]);
            assert_eq!(trav.length_of(&p), 1);
        }
    
        #[test]
        fn test_lens_as_traversal_over_modifies_only_x() {
            let trav = point_x_lens().as_traversal();
            let p = Point { x: 2.0, y: 5.0 };
            let result = trav.over(|x| x * 10.0, &p);
            // x is scaled; y is preserved — Traversal has the same semantics as Lens
            assert_eq!(result, Point { x: 20.0, y: 5.0 });
        }
    
        #[test]
        fn test_prism_as_traversal_collect_hit() {
            let trav = circle_radius_prism().as_traversal();
            // Circle matches → exactly 1 focus
            assert_eq!(trav.collect_all(&Shape::Circle { radius: 7.0 }), vec![7.0]);
            assert_eq!(trav.length_of(&Shape::Circle { radius: 7.0 }), 1);
        }
    
        #[test]
        fn test_prism_as_traversal_collect_miss() {
            let trav = circle_radius_prism().as_traversal();
            // Rect doesn't match → 0 focuses
            let rect = Shape::Rect {
                width: 4.0,
                height: 3.0,
            };
            assert_eq!(trav.collect_all(&rect), vec![]);
            assert_eq!(trav.length_of(&rect), 0);
        }
    
        #[test]
        fn test_prism_as_traversal_over_hit_scales_radius() {
            let trav = circle_radius_prism().as_traversal();
            let result = trav.over(|r| r * 2.0, &Shape::Circle { radius: 5.0 });
            assert_eq!(result, Shape::Circle { radius: 10.0 });
        }
    
        #[test]
        fn test_prism_as_traversal_over_miss_is_noop() {
            let trav = circle_radius_prism().as_traversal();
            let rect = Shape::Rect {
                width: 4.0,
                height: 3.0,
            };
            // over on a miss returns a structural clone — no modification
            assert_eq!(trav.over(|r| r * 2.0, &rect), rect);
        }
    
        // -- Approach 2: trait-based hierarchy ------------------------------------
    
        #[test]
        fn test_trait_lens_count_is_always_one() {
            let lens = PointXLens;
            let p = Point { x: 1.0, y: 2.0 };
            assert_eq!(count_focuses(&lens, &p), 1);
        }
    
        #[test]
        fn test_trait_prism_count_hit_and_miss() {
            let prism = CircleRadiusPrism;
            assert_eq!(count_focuses(&prism, &Shape::Circle { radius: 3.0 }), 1);
            assert_eq!(
                count_focuses(
                    &prism,
                    &Shape::Rect {
                        width: 1.0,
                        height: 1.0
                    }
                ),
                0
            );
        }
    
        #[test]
        fn test_trait_iso_count_is_always_one() {
            let iso = CelsiusIso;
            // Iso is both Lens and Prism, so count is always 1
            assert_eq!(count_focuses(&iso, &Celsius(0.0)), 1);
            assert_eq!(count_focuses(&iso, &Celsius(-273.15)), 1);
        }
    
        #[test]
        fn test_trait_lens_view_set_and_over_t() {
            let lens = PointXLens;
            let p = Point { x: 1.5, y: 9.0 };
            assert_eq!(lens.view(&p), 1.5);
            assert_eq!(lens.set(42.0, &p), Point { x: 42.0, y: 9.0 });
            assert_eq!(lens.over_t(&|x| x + 1.0, &p), Point { x: 2.5, y: 9.0 });
        }
    
        #[test]
        fn test_trait_prism_preview_review_and_over_t() {
            let prism = CircleRadiusPrism;
            let circle = Shape::Circle { radius: 4.0 };
            let rect = Shape::Rect {
                width: 2.0,
                height: 3.0,
            };
            assert_eq!(prism.preview(&circle), Some(4.0));
            assert_eq!(prism.preview(&rect), None);
            assert_eq!(prism.review(8.0), Shape::Circle { radius: 8.0 });
            // over_t on a miss returns the original structure unchanged
            assert_eq!(prism.over_t(&|r| r * 2.0, &rect), rect);
        }
    
        #[test]
        fn test_trait_iso_acts_as_both_lens_and_prism() {
            let iso = CelsiusIso;
            let c = Celsius(20.0);
            // As LensOptic
            assert_eq!(iso.view(&c), 20.0);
            assert_eq!(iso.set(37.0, &c), Celsius(37.0));
            // As PrismOptic — preview always Some for an Iso
            assert_eq!(iso.preview(&c), Some(20.0));
            assert_eq!(iso.review(100.0), Celsius(100.0));
            // As IsoOptic
            assert_eq!(iso.reverse_get(0.0), Celsius(0.0));
        }
    
        #[test]
        fn test_first_focus_generic_across_all_optic_types() {
            let lens = PointXLens;
            let prism = CircleRadiusPrism;
            let iso = CelsiusIso;
            // Lens: always Some
            assert_eq!(first_focus(&lens, &Point { x: 7.0, y: 0.0 }), Some(7.0));
            // Prism: Some on hit, None on miss
            assert_eq!(
                first_focus(&prism, &Shape::Circle { radius: 3.0 }),
                Some(3.0)
            );
            assert_eq!(
                first_focus(
                    &prism,
                    &Shape::Rect {
                        width: 1.0,
                        height: 2.0
                    }
                ),
                None
            );
            // Iso: always Some
            assert_eq!(first_focus(&iso, &Celsius(99.0)), Some(99.0));
        }
    
        #[test]
        fn test_hierarchy_upcasting_same_semantics_lens_and_traversal() {
            // Using a Lens directly and via Traversal upcast must give the same result
            let lens = point_x_lens();
            let trav = point_x_lens().as_traversal();
            let p = Point { x: 5.0, y: 3.0 };
            let via_lens = lens.over(|x| x + 1.0, &p);
            let via_trav = trav.over(|x| x + 1.0, &p);
            assert_eq!(via_lens, via_trav);
        }
    }

    Deep Comparison

    OCaml vs Rust: Optics Hierarchy — Iso ⊂ Lens ⊂ Traversal, Iso ⊂ Prism ⊂ Traversal

    Side-by-Side Code

    OCaml

    (* The hierarchy as a unified discriminated union *)
    type ('s, 'a) optic =
      | Iso_op     of { get : 's -> 'a; reverse_get : 'a -> 's }
      | Lens_op    of { get : 's -> 'a; set : 'a -> 's -> 's }
      | Prism_op   of { preview : 's -> 'a option; review : 'a -> 's }
      | Traversal_op of { over : ('a -> 'a) -> 's -> 's; to_list : 's -> 'a list }
    
    (* Upcast: every Lens is a Traversal *)
    let lens_as_traversal { get; set } =
      { over   = (fun f s -> set (f (get s)) s);
        to_list = (fun s  -> [get s]) }
    
    (* Upcast: every Prism is a Traversal *)
    let prism_as_traversal { preview; review } =
      { over   = (fun f s -> match preview s with Some a -> review (f a) | None -> s);
        to_list = (fun s  -> match preview s with Some a -> [a]           | None -> []) }
    
    (* Upcast: every Iso is a Lens *)
    let iso_as_lens { get; reverse_get } =
      { get; set = (fun a _s -> reverse_get a) }
    
    (* Generic function at the Traversal level — accepts any optic *)
    let collect_all trav s = trav.to_list s
    

    Rust (struct-based — mirrors OCaml record approach)

    pub struct Lens<S, A> {
        get_fn: Box<dyn Fn(&S) -> A>,
        set_fn: Box<dyn Fn(A, &S) -> S>,
    }
    
    impl<S: Clone + 'static, A: Clone + 'static> Lens<S, A> {
        /// Every Lens is a Traversal.
        pub fn as_traversal(self) -> Traversal<S, A> {
            use std::rc::Rc;
            let get_fn = Rc::new(self.get_fn);
            let get_fn2 = Rc::clone(&get_fn);
            let set_fn = self.set_fn;
            Traversal::new(
                move |f, s| { let a = get_fn(s); set_fn(f(&a), s) },
                move |s| vec![get_fn2(s)],
            )
        }
    }
    

    Rust (trait-based — zero-cost compile-time dispatch)

    pub trait OpticBase<S: Clone, A: Clone> {
        fn collect(&self, s: &S) -> Vec<A>;
        fn over_t(&self, f: &dyn Fn(&A) -> A, s: &S) -> S;
    }
    
    pub trait LensOptic<S: Clone, A: Clone>: OpticBase<S, A> {
        fn view(&self, s: &S) -> A;
        fn set(&self, a: A, s: &S) -> S;
    }
    
    pub trait PrismOptic<S: Clone, A: Clone>: OpticBase<S, A> {
        fn preview(&self, s: &S) -> Option<A>;
        fn review(&self, a: A) -> S;
    }
    
    pub trait IsoOptic<S: Clone, A: Clone>: LensOptic<S, A> + PrismOptic<S, A> {
        fn reverse_get(&self, a: A) -> S;
    }
    
    // CelsiusIso implements ALL FOUR traits — proving it sits at the top of the hierarchy.
    pub struct CelsiusIso;
    impl OpticBase<Celsius, f64> for CelsiusIso { /* ... */ }
    impl LensOptic<Celsius, f64> for CelsiusIso { /* ... */ }
    impl PrismOptic<Celsius, f64> for CelsiusIso { /* ... */ }
    impl IsoOptic<Celsius, f64>  for CelsiusIso { /* ... */ }
    
    // Generic code at the base level — accepts Lens, Prism, or Iso equally
    fn first_focus<S: Clone, A: Clone>(optic: &dyn OpticBase<S, A>, s: &S) -> Option<A> {
        optic.collect(s).into_iter().next()
    }
    

    Type Signatures

    ConceptOCamlRust
    Traversal{ over : ('a -> 'a) -> 's -> 's; to_list : 's -> 'a list }struct Traversal<S, A> with boxed closures
    Lens{ get : 's -> 'a; set : 'a -> 's -> 's }struct Lens<S, A> with get_fn + set_fn
    Prism{ preview : 's -> 'a option; review : 'a -> 's }struct Prism<S, A> with preview_fn + review_fn
    Iso{ get : 's -> 'a; reverse_get : 'a -> 's }struct Iso<S, A> with get_fn + reverse_get_fn
    Lens upcastlet lens_as_traversal { get; set } = ...fn as_traversal(self) -> Traversal<S, A>
    Iso set (lossless)set = (fun a _s -> reverse_get a)move \|a, _\| rev(a)_s discarded
    Prism miss in over\| None -> sNone => s.clone()Clone required
    Trait hierarchyModule-level type classes (first-class modules)Supertrait bounds: LensOptic: OpticBase
    Generic functionlet collect_all trav s = trav.to_list sfn collect_all(optic: &dyn OpticBase<S,A>,...)

    Key Insights

  • Sharing closures between two consumers: In OCaml, record fields are values that can be freely aliased. In Rust, a Box<dyn Fn> can only be moved once. When both the over closure and the to_list closure in Traversal need the same get_fn, Rust requires Rc::new(get_fn) + Rc::clone to share ownership — a pattern invisible in OCaml.
  • **Iso set discards the original structure**: An Iso's set(a, _s) = reverse_get(a) throws away _s. This is only safe because the Iso is lossless — a carries full information. A regular Lens cannot do this. Rust makes this explicit by marking the parameter _ (intentionally unused), whereas OCaml uses _s by convention.
  • Trait supertraits vs OCaml module hierarchies: OCaml expresses the hierarchy through functor composition and first-class module inclusion. Rust uses supertrait bounds (LensOptic<S,A>: OpticBase<S,A>), which enforces the hierarchy statically at compile time. A type implementing IsoOptic must implement all four traits — the compiler verifies membership in the hierarchy.
  • Two representation strategies: Rust offers a choice OCaml doesn't force you to make explicitly. The struct-based approach (Lens<S,A>, Prism<S,A>) allows runtime composition and returning optics from functions — closer to OCaml's first-class records. The trait-based approach (LensOptic, PrismOptic) is zero-cost with monomorphisation but requires a named type per optic instance.
  • **Clone as an explicit effect**: OCaml's s in None -> s is structurally shared for free. In Rust's None => s.clone(), Clone is an explicit operation. Requiring S: Clone in Prism::as_traversal makes the cost visible at the type level and is absent from the Lens path, which never needs to copy the structure.
  • When to Use Each Style

    Use struct-based optics when: you need to return optics from functions, build them at runtime, or compose them dynamically (e.g., configuring which fields to traverse based on user input).

    Use trait-based optics when: the optic is statically known, you want zero-cost abstraction via monomorphisation, and you want the type system to enforce hierarchy membership at compile time.

    Exercises

  • Implement optic_sum<O: AsTraversal<S, f64>>(optic: &O, s: &S) -> f64 that works for lenses, prisms, and traversals.
  • Write optic_count<O: AsTraversal>(optic: &O, s: &S) -> usize that counts the number of focused elements.
  • Verify the hierarchy composition rules: lens_then_prism produces an affine traversal, lens_then_traversal produces a traversal.
  • Open Source Repos