ExamplesBy LevelBy TopicLearning Paths
210 Advanced

Iso Basics — Isomorphisms

Functional Programming

Tutorial

The Problem

An isomorphism is a lossless, reversible transformation between two types: Celsius <-> Fahrenheit, String <-> Vec<u8>, (A, B) <-> (B, A). An Iso optic captures this bidirectional mapping as a first-class value. Unlike a lens (asymmetric: get and set are different operations) or prism (partial), an iso is fully symmetric: get and reverse_get are inverses of each other. Isos are the most restrictive optic — they appear at the top of the optics hierarchy.

🎯 Learning Outcomes

  • • Understand isos as lossless bidirectional transformations
  • • Implement Iso<S, A> with get and reverse_get
  • • Verify the iso laws: get(reverse_get(a)) == a and reverse_get(get(s)) == s
  • • See how isos fit into the optics hierarchy as the most specific optic
  • Code Example

    pub struct Iso<S, A> {
        pub get: Box<dyn Fn(&S) -> A>,
        pub reverse_get: Box<dyn Fn(&A) -> S>,
    }
    
    impl<S: 'static, A: 'static> Iso<S, A> {
        pub fn new(
            get: impl Fn(&S) -> A + 'static,
            reverse_get: impl Fn(&A) -> S + 'static,
        ) -> Self {
            Iso { get: Box::new(get), reverse_get: Box::new(reverse_get) }
        }
    
        pub fn reverse(self) -> Iso<A, S> {
            Iso { get: self.reverse_get, reverse_get: self.get }
        }
    
        pub fn compose<B: 'static>(self, other: Iso<A, B>) -> Iso<S, B> {
            let get_self  = self.get;
            let rev_self  = self.reverse_get;
            let get_other = other.get;
            let rev_other = other.reverse_get;
            Iso {
                get:         Box::new(move |s| get_other(&get_self(s))),
                reverse_get: Box::new(move |b| rev_self(&rev_other(b))),
            }
        }
    }
    
    pub fn celsius_fahrenheit() -> Iso<f64, f64> {
        Iso::new(
            |c| c * 9.0 / 5.0 + 32.0,
            |f| (f - 32.0) * 5.0 / 9.0,
        )
    }

    Key Differences

  • Symmetry: Isos are symmetric — flip produces a valid iso; lenses and prisms have no flip operation.
  • Laws: Iso laws are stronger than lens laws — both directions must be inverses; lens laws only require round-trip consistency in one direction.
  • Hierarchy: Every iso is a lens, prism, and traversal; going from iso to lens drops the reverse_get — a narrowing of capabilities.
  • Practical use: Isos arise when two representations are fully equivalent: Celsius/Fahrenheit, Unix timestamp/DateTime, RGB/HSL.
  • OCaml Approach

    OCaml's iso pattern:

    type ('s, 'a) iso = {
      get : 's -> 'a;
      reverse_get : 'a -> 's;
    }
    let flip iso = { get = iso.reverse_get; reverse_get = iso.get }
    let ( >> ) i1 i2 = { get = (fun s -> i2.get (i1.get s));
                         reverse_get = (fun a -> i1.reverse_get (i2.reverse_get a)) }
    

    The (>>) composition operator chains isos naturally. OCaml's infix operators make composition more readable.

    Full Source

    #![allow(clippy::all)]
    /// An isomorphism: a lossless, bidirectional transformation between types S and A.
    ///
    /// Laws:
    ///   get(reverse_get(a)) == a   (right identity)
    ///   reverse_get(get(s)) == s   (left identity)
    pub struct Iso<S, A> {
        pub get: Box<dyn Fn(&S) -> A>,
        pub reverse_get: Box<dyn Fn(&A) -> S>,
    }
    
    impl<S: 'static, A: 'static> Iso<S, A> {
        pub fn new(get: impl Fn(&S) -> A + 'static, reverse_get: impl Fn(&A) -> S + 'static) -> Self {
            Iso {
                get: Box::new(get),
                reverse_get: Box::new(reverse_get),
            }
        }
    
        /// Swap directions: the inverse Iso<A, S>.
        pub fn reverse(self) -> Iso<A, S> {
            Iso {
                get: self.reverse_get,
                reverse_get: self.get,
            }
        }
    
        /// Compose two isos: Iso<S,A> then Iso<A,B> → Iso<S,B>.
        pub fn compose<B: 'static>(self, other: Iso<A, B>) -> Iso<S, B>
        where
            A: 'static,
        {
            let get_self = self.get;
            let rev_self = self.reverse_get;
            let get_other = other.get;
            let rev_other = other.reverse_get;
    
            Iso {
                get: Box::new(move |s| get_other(&get_self(s))),
                reverse_get: Box::new(move |b| rev_self(&rev_other(b))),
            }
        }
    
        /// Apply `get`.
        pub fn get(&self, s: &S) -> A {
            (self.get)(s)
        }
    
        /// Apply `reverse_get`.
        pub fn reverse_get(&self, a: &A) -> S {
            (self.reverse_get)(a)
        }
    }
    
    // ---------------------------------------------------------------------------
    // Approach 1: numeric unit conversions
    // ---------------------------------------------------------------------------
    
    /// Celsius ↔ Fahrenheit
    pub fn celsius_fahrenheit() -> Iso<f64, f64> {
        Iso::new(|c| c * 9.0 / 5.0 + 32.0, |f| (f - 32.0) * 5.0 / 9.0)
    }
    
    /// Meters ↔ Kilometers
    pub fn meters_kilometers() -> Iso<f64, f64> {
        Iso::new(|m| m / 1000.0, |km| km * 1000.0)
    }
    
    // ---------------------------------------------------------------------------
    // Approach 2: newtype wrappers
    // ---------------------------------------------------------------------------
    
    #[derive(Debug, Clone, PartialEq)]
    pub struct Celsius(pub f64);
    
    #[derive(Debug, Clone, PartialEq)]
    pub struct Fahrenheit(pub f64);
    
    /// Celsius newtype ↔ raw f64
    pub fn celsius_raw() -> Iso<Celsius, f64> {
        Iso::new(|c: &Celsius| c.0, |f: &f64| Celsius(*f))
    }
    
    /// Fahrenheit newtype ↔ raw f64
    pub fn fahrenheit_raw() -> Iso<Fahrenheit, f64> {
        Iso::new(|f: &Fahrenheit| f.0, |v: &f64| Fahrenheit(*v))
    }
    
    // ---------------------------------------------------------------------------
    // Approach 3: String ↔ Vec<char> (structural Iso)
    // ---------------------------------------------------------------------------
    
    pub fn string_chars() -> Iso<String, Vec<char>> {
        Iso::new(
            |s: &String| s.chars().collect(),
            |cs: &Vec<char>| cs.iter().collect(),
        )
    }
    
    // ---------------------------------------------------------------------------
    // Tests
    // ---------------------------------------------------------------------------
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        const EPSILON: f64 = 1e-9;
    
        fn approx_eq(a: f64, b: f64) -> bool {
            (a - b).abs() < EPSILON
        }
    
        // --- celsius_fahrenheit roundtrip ---
    
        #[test]
        fn test_celsius_to_fahrenheit() {
            let iso = celsius_fahrenheit();
            assert!(approx_eq(iso.get(&0.0), 32.0));
            assert!(approx_eq(iso.get(&100.0), 212.0));
            assert!(approx_eq(iso.get(&-40.0), -40.0));
        }
    
        #[test]
        fn test_fahrenheit_to_celsius() {
            let iso = celsius_fahrenheit();
            assert!(approx_eq(iso.reverse_get(&32.0), 0.0));
            assert!(approx_eq(iso.reverse_get(&212.0), 100.0));
        }
    
        #[test]
        fn test_celsius_fahrenheit_roundtrip() {
            let iso = celsius_fahrenheit();
            let start = 37.0_f64;
            // forward then back
            assert!(approx_eq(iso.reverse_get(&iso.get(&start)), start));
            // back then forward
            let f = 98.6_f64;
            assert!(approx_eq(iso.get(&iso.reverse_get(&f)), f));
        }
    
        // --- reverse swaps directions ---
    
        #[test]
        fn test_reverse_swaps_directions() {
            let iso = celsius_fahrenheit().reverse(); // now Iso<f64, f64> but F→C
            assert!(approx_eq(iso.get(&32.0), 0.0));
            assert!(approx_eq(iso.reverse_get(&0.0), 32.0));
        }
    
        // --- compose chains two isos ---
    
        #[test]
        fn test_compose_meters_to_kilometers_then_to_string() {
            // Compose Iso<f64,f64> (m→km) with a string iso for demonstration.
            // We use a simple km→String / String→km numeric iso.
            let m_to_km = meters_kilometers();
            let km_to_string: Iso<f64, String> = Iso::new(
                |km: &f64| format!("{km:.3}"),
                |s: &String| s.parse::<f64>().unwrap_or(0.0),
            );
            let composed = m_to_km.compose(km_to_string);
            // 1500 m → 1.5 km → "1.500"
            assert_eq!(composed.get(&1500.0), "1.500");
            // "1.500" → 1.5 km → 1500 m
            assert!(approx_eq(
                composed.reverse_get(&"1.500".to_string()),
                1500.0
            ));
        }
    
        // --- string_chars roundtrip ---
    
        #[test]
        fn test_string_chars_roundtrip() {
            let iso = string_chars();
            let s = "hello".to_string();
            assert_eq!(iso.reverse_get(&iso.get(&s)), s);
    
            let chars = vec!['r', 'u', 's', 't'];
            assert_eq!(iso.get(&iso.reverse_get(&chars)), chars);
        }
    
        #[test]
        fn test_string_chars_empty() {
            let iso = string_chars();
            let empty = String::new();
            assert_eq!(iso.get(&empty), vec![]);
            assert_eq!(iso.reverse_get(&vec![]), empty);
        }
    
        // --- newtype wrappers ---
    
        #[test]
        fn test_celsius_newtype_roundtrip() {
            let iso = celsius_raw();
            let c = Celsius(37.5);
            assert!(approx_eq(iso.get(&c), 37.5));
            assert_eq!(iso.reverse_get(&37.5), Celsius(37.5));
            // full roundtrip
            assert_eq!(iso.reverse_get(&iso.get(&c)), c);
        }
    
        // --- Iso laws: get ∘ reverse_get = id and reverse_get ∘ get = id ---
    
        #[test]
        fn test_iso_laws_meters_kilometers() {
            let iso = meters_kilometers();
            let m = 42_000.0_f64;
            assert!(approx_eq(iso.reverse_get(&iso.get(&m)), m));
            let km = 42.0_f64;
            assert!(approx_eq(iso.get(&iso.reverse_get(&km)), km));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        const EPSILON: f64 = 1e-9;
    
        fn approx_eq(a: f64, b: f64) -> bool {
            (a - b).abs() < EPSILON
        }
    
        // --- celsius_fahrenheit roundtrip ---
    
        #[test]
        fn test_celsius_to_fahrenheit() {
            let iso = celsius_fahrenheit();
            assert!(approx_eq(iso.get(&0.0), 32.0));
            assert!(approx_eq(iso.get(&100.0), 212.0));
            assert!(approx_eq(iso.get(&-40.0), -40.0));
        }
    
        #[test]
        fn test_fahrenheit_to_celsius() {
            let iso = celsius_fahrenheit();
            assert!(approx_eq(iso.reverse_get(&32.0), 0.0));
            assert!(approx_eq(iso.reverse_get(&212.0), 100.0));
        }
    
        #[test]
        fn test_celsius_fahrenheit_roundtrip() {
            let iso = celsius_fahrenheit();
            let start = 37.0_f64;
            // forward then back
            assert!(approx_eq(iso.reverse_get(&iso.get(&start)), start));
            // back then forward
            let f = 98.6_f64;
            assert!(approx_eq(iso.get(&iso.reverse_get(&f)), f));
        }
    
        // --- reverse swaps directions ---
    
        #[test]
        fn test_reverse_swaps_directions() {
            let iso = celsius_fahrenheit().reverse(); // now Iso<f64, f64> but F→C
            assert!(approx_eq(iso.get(&32.0), 0.0));
            assert!(approx_eq(iso.reverse_get(&0.0), 32.0));
        }
    
        // --- compose chains two isos ---
    
        #[test]
        fn test_compose_meters_to_kilometers_then_to_string() {
            // Compose Iso<f64,f64> (m→km) with a string iso for demonstration.
            // We use a simple km→String / String→km numeric iso.
            let m_to_km = meters_kilometers();
            let km_to_string: Iso<f64, String> = Iso::new(
                |km: &f64| format!("{km:.3}"),
                |s: &String| s.parse::<f64>().unwrap_or(0.0),
            );
            let composed = m_to_km.compose(km_to_string);
            // 1500 m → 1.5 km → "1.500"
            assert_eq!(composed.get(&1500.0), "1.500");
            // "1.500" → 1.5 km → 1500 m
            assert!(approx_eq(
                composed.reverse_get(&"1.500".to_string()),
                1500.0
            ));
        }
    
        // --- string_chars roundtrip ---
    
        #[test]
        fn test_string_chars_roundtrip() {
            let iso = string_chars();
            let s = "hello".to_string();
            assert_eq!(iso.reverse_get(&iso.get(&s)), s);
    
            let chars = vec!['r', 'u', 's', 't'];
            assert_eq!(iso.get(&iso.reverse_get(&chars)), chars);
        }
    
        #[test]
        fn test_string_chars_empty() {
            let iso = string_chars();
            let empty = String::new();
            assert_eq!(iso.get(&empty), vec![]);
            assert_eq!(iso.reverse_get(&vec![]), empty);
        }
    
        // --- newtype wrappers ---
    
        #[test]
        fn test_celsius_newtype_roundtrip() {
            let iso = celsius_raw();
            let c = Celsius(37.5);
            assert!(approx_eq(iso.get(&c), 37.5));
            assert_eq!(iso.reverse_get(&37.5), Celsius(37.5));
            // full roundtrip
            assert_eq!(iso.reverse_get(&iso.get(&c)), c);
        }
    
        // --- Iso laws: get ∘ reverse_get = id and reverse_get ∘ get = id ---
    
        #[test]
        fn test_iso_laws_meters_kilometers() {
            let iso = meters_kilometers();
            let m = 42_000.0_f64;
            assert!(approx_eq(iso.reverse_get(&iso.get(&m)), m));
            let km = 42.0_f64;
            assert!(approx_eq(iso.get(&iso.reverse_get(&km)), km));
        }
    }

    Deep Comparison

    OCaml vs Rust: Iso Basics — Lossless Bidirectional Transformations

    Side-by-Side Code

    OCaml

    type ('s, 'a) iso = {
      get         : 's -> 'a;
      reverse_get : 'a -> 's;
    }
    
    let celsius_fahrenheit : (float, float) iso = {
      get         = (fun c -> c *. 9.0 /. 5.0 +. 32.0);
      reverse_get = (fun f -> (f -. 32.0) *. 5.0 /. 9.0);
    }
    
    (* Reverse: swap field values *)
    let reverse iso = { get = iso.reverse_get; reverse_get = iso.get }
    
    (* Compose: chain two isos *)
    let compose iso1 iso2 = {
      get         = (fun s -> iso2.get (iso1.get s));
      reverse_get = (fun b -> iso1.reverse_get (iso2.reverse_get b));
    }
    

    Rust (idiomatic — trait objects for type erasure)

    pub struct Iso<S, A> {
        pub get: Box<dyn Fn(&S) -> A>,
        pub reverse_get: Box<dyn Fn(&A) -> S>,
    }
    
    impl<S: 'static, A: 'static> Iso<S, A> {
        pub fn new(
            get: impl Fn(&S) -> A + 'static,
            reverse_get: impl Fn(&A) -> S + 'static,
        ) -> Self {
            Iso { get: Box::new(get), reverse_get: Box::new(reverse_get) }
        }
    
        pub fn reverse(self) -> Iso<A, S> {
            Iso { get: self.reverse_get, reverse_get: self.get }
        }
    
        pub fn compose<B: 'static>(self, other: Iso<A, B>) -> Iso<S, B> {
            let get_self  = self.get;
            let rev_self  = self.reverse_get;
            let get_other = other.get;
            let rev_other = other.reverse_get;
            Iso {
                get:         Box::new(move |s| get_other(&get_self(s))),
                reverse_get: Box::new(move |b| rev_self(&rev_other(b))),
            }
        }
    }
    
    pub fn celsius_fahrenheit() -> Iso<f64, f64> {
        Iso::new(
            |c| c * 9.0 / 5.0 + 32.0,
            |f| (f - 32.0) * 5.0 / 9.0,
        )
    }
    

    Rust (functional — newtype wrapper Iso)

    #[derive(Debug, Clone, PartialEq)]
    pub struct Celsius(pub f64);
    
    pub fn celsius_raw() -> Iso<Celsius, f64> {
        Iso::new(
            |c: &Celsius| c.0,
            |f: &f64| Celsius(*f),
        )
    }
    

    Type Signatures

    ConceptOCamlRust
    Iso type('s, 'a) iso (record)Iso<S, A> (struct with Box<dyn Fn> fields)
    Forward functionget : 's -> 'aBox<dyn Fn(&S) -> A>
    Backward functionreverse_get : 'a -> 'sBox<dyn Fn(&A) -> S>
    Reverseswap record fields, value semanticsself-consuming method, moves ownership
    Composefunction composition, pureclosures capture moved Box<dyn Fn> fields
    Newtype unwraplet Meters m = xtuple struct field access x.0
    Lifetime requirementimplicit (GC)'static bound on type params

    Key Insights

  • Record vs struct with closures: OCaml's record { get; reverse_get } maps cleanly to a Rust struct, but Rust functions stored in structs require Box<dyn Fn(...)> for heap-allocated, type-erased closures — or generic type parameters if monomorphisation is preferred.
  • **Ownership on reverse:** In Rust, reverse consumes self (moving the Box<dyn Fn> fields). This prevents using the original Iso after reversal, which is correct — you now hold the inverse. OCaml shares field values by default (boxed on the heap under GC), so no explicit transfer is needed.
  • **'static lifetime bound:** Rust closures stored in Box<dyn Fn> must be 'static when the struct escapes the stack. OCaml's GC handles this transparently. This surfaces when composing Isos: each closure must own (not borrow) its captured values.
  • Newtype Isos: OCaml uses algebraic constructors (Meters of float); Rust uses tuple structs (struct Celsius(f64)). Both let you wrap and unwrap a primitive. The Iso makes the wrap/unwrap contract explicit and composable rather than scattered across the codebase.
  • Roundtrip laws as tests: Neither language enforces the Iso laws (get ∘ reverse_get = id) in the type system. Rust property-based tests (or manual assert! tests) serve as the enforcement mechanism — just as OCaml uses assert at the top level.
  • When to Use Each Style

    Use idiomatic Rust (trait objects) when the Iso will be passed around as a value, returned from functions, or composed dynamically at runtime — the Box<dyn Fn> overhead is acceptable and the API is clean.

    Use generic type parameters (struct Iso<S, A, F, G>) when performance matters and the Iso is used at a fixed, known call site — this enables monomorphisation and inlining, at the cost of more complex type signatures.

    Use newtype Isos when you want the type system to prevent mixing up unit-bearing values (e.g., Celsius vs Fahrenheit) — the Iso then documents and enforces the only safe conversion path.

    Exercises

  • Implement km_miles_iso between kilometers and miles and verify both law directions.
  • Compose celsius_fahrenheit_iso with fahrenheit_kelvin_iso to produce celsius_kelvin_iso.
  • Implement swap_iso: Iso<(A, B), (B, A)> that swaps the components of a pair.
  • Open Source Repos