ExamplesBy LevelBy TopicLearning Paths
231 Intermediate

Product Types

Type SystemFunctional Patterns

Tutorial Video

Text description (accessibility)

This video demonstrates the "Product Types" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Type System, Functional Patterns. Demonstrate product types (the categorical product): structs/records that bundle multiple fields, tuples as anonymous products, and the `curry`/`uncurry` isomorphism that shows tupled and curried functions are equivalent. Key difference from OCaml: 1. **Mutation default:** OCaml records are immutable by default; Rust structs require `mut` binding to mutate fields.

Tutorial

The Problem

Demonstrate product types (the categorical product): structs/records that bundle multiple fields, tuples as anonymous products, and the curry/uncurry isomorphism that shows tupled and curried functions are equivalent.

🎯 Learning Outcomes

  • • How OCaml records map directly to Rust struct types with named fields
  • • Why Rust tuples are value types consumed on move, unlike OCaml's persistent pairs
  • • How uncurry and curry encode the categorical isomorphism (A × B → C) ≅ (A → B → C)
  • • Why Rust requires Rc for shared ownership in closures that return other closures
  • 🦀 The Rust Way

    Rust structs are nominal (not structural) types. Tuples are moved on access, so fst and snd consume their argument. Implementing curry requires Rc to share the inner function across multiple calls, since Rust closures take ownership. The method syntax (impl Point2d) lets behaviour live alongside data, which is more idiomatic than free functions for domain types.

    Code Example

    #[derive(Debug, Clone, Copy, PartialEq)]
    pub struct Point2d { pub x: f64, pub y: f64 }
    
    impl Point2d {
        pub fn distance_to(&self, other: &Self) -> f64 {
            let dx = self.x - other.x;
            let dy = self.y - other.y;
            (dx * dx + dy * dy).sqrt()
        }
    }
    
    pub fn swap<A, B>(pair: (A, B)) -> (B, A) { (pair.1, pair.0) }
    pub fn fst<A, B>(pair: (A, B)) -> A { pair.0 }
    pub fn snd<A, B>(pair: (A, B)) -> B { pair.1 }

    Key Differences

  • Mutation default: OCaml records are immutable by default; Rust structs require mut binding to mutate fields.
  • Tuple consumption: OCaml pairs can be projected freely; Rust tuple fields are moved on fst/snd unless the type is Copy.
  • Currying: OCaml functions are automatically curried; Rust requires explicit closure wrapping and Rc for shared state.
  • Method vs free fn: Rust encourages impl Type { fn method(&self) } for type-associated behaviour; OCaml uses modules.
  • OCaml Approach

    OCaml records ({ x: float; y: float }) are immutable by default and structurally typed within a module scope. Tuples are first-class values, pattern-matched directly. curry/uncurry are straightforward because OCaml functions are automatically curried — applying f a b is identical to f (a, b) after uncurry.

    Full Source

    #![allow(clippy::all)]
    // Product types: combine multiple types into one.
    // In category theory, the categorical product.
    
    use std::rc::Rc;
    
    // --- Record product types (structs in Rust) ---
    
    #[derive(Debug, Clone, Copy, PartialEq)]
    pub struct Point2d {
        pub x: f64,
        pub y: f64,
    }
    
    #[derive(Debug, Clone, Copy, PartialEq)]
    pub struct Point3d {
        pub x: f64,
        pub y: f64,
        pub z: f64,
    }
    
    // --- Tuple operations ---
    
    // Solution 1: Idiomatic — swap, fst, snd as free functions over generic tuples
    pub fn swap<A, B>(pair: (A, B)) -> (B, A) {
        (pair.1, pair.0)
    }
    
    pub fn fst<A, B>(pair: (A, B)) -> A {
        pair.0
    }
    
    pub fn snd<A, B>(pair: (A, B)) -> B {
        pair.1
    }
    
    pub fn pair<A, B>(a: A, b: B) -> (A, B) {
        (a, b)
    }
    
    // --- Curry / Uncurry ---
    
    // Solution 2: Functional — uncurry converts a two-arg function into a tuple-arg function
    // OCaml: let uncurry f (a, b) = f a b
    pub fn uncurry<A, B, C>(f: impl Fn(A, B) -> C) -> impl Fn((A, B)) -> C {
        move |(a, b)| f(a, b)
    }
    
    // Solution 2b: curry converts a tuple-arg function into a two-step curried function.
    // Uses Rc for shared ownership of the inner function across calls.
    // OCaml: let curry f a b = f (a, b)
    pub fn curry<A: Clone + 'static, B: 'static, C: 'static>(
        f: impl Fn((A, B)) -> C + 'static,
    ) -> impl Fn(A) -> Box<dyn Fn(B) -> C> {
        let f = Rc::new(f);
        move |a: A| {
            let f = Rc::clone(&f);
            let a = a.clone();
            Box::new(move |b: B| f((a.clone(), b)))
        }
    }
    
    // --- Distance ---
    
    // Solution 1: Free function (matches OCaml style)
    pub fn distance(p: &Point2d, q: &Point2d) -> f64 {
        let dx = p.x - q.x;
        let dy = p.y - q.y;
        (dx * dx + dy * dy).sqrt()
    }
    
    // Solution 2: Method style (idiomatic Rust — groups behaviour with the type)
    impl Point2d {
        pub fn distance_to(&self, other: &Self) -> f64 {
            let dx = self.x - other.x;
            let dy = self.y - other.y;
            (dx * dx + dy * dy).sqrt()
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_swap() {
            assert_eq!(swap((1, "hello")), ("hello", 1));
            assert_eq!(swap((true, 42u8)), (42u8, true));
        }
    
        #[test]
        fn test_fst_snd() {
            assert_eq!(fst((42, "hello")), 42);
            assert_eq!(snd((42, "hello")), "hello");
        }
    
        #[test]
        fn test_pair_constructor() {
            assert_eq!(pair(1, 2), (1, 2));
            assert_eq!(pair("a", true), ("a", true));
        }
    
        #[test]
        fn test_uncurry() {
            let add_pair = uncurry(|a: i32, b: i32| a + b);
            assert_eq!(add_pair((3, 4)), 7);
            assert_eq!(add_pair((0, 0)), 0);
            assert_eq!(add_pair((-1, 1)), 0);
        }
    
        #[test]
        fn test_curry_roundtrip() {
            let add_pair = uncurry(|a: i32, b: i32| a + b);
            let curried = curry(add_pair);
            assert_eq!(curried(3)(4), 7);
            assert_eq!(curried(10)(5), 15);
            assert_eq!(curried(0)(0), 0);
        }
    
        #[test]
        fn test_distance_free_fn() {
            let origin = Point2d { x: 0.0, y: 0.0 };
            let p = Point2d { x: 3.0, y: 4.0 };
            assert!((distance(&origin, &p) - 5.0).abs() < 1e-10);
        }
    
        #[test]
        fn test_distance_zero() {
            let p = Point2d { x: 1.0, y: 2.0 };
            assert_eq!(distance(&p, &p), 0.0);
        }
    
        #[test]
        fn test_distance_method() {
            let origin = Point2d { x: 0.0, y: 0.0 };
            let p = Point2d { x: 3.0, y: 4.0 };
            assert!((origin.distance_to(&p) - 5.0).abs() < 1e-10);
        }
    
        #[test]
        fn test_point3d_fields() {
            let p = Point3d {
                x: 1.0,
                y: 2.0,
                z: 3.0,
            };
            assert_eq!(p.x, 1.0);
            assert_eq!(p.y, 2.0);
            assert_eq!(p.z, 3.0);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_swap() {
            assert_eq!(swap((1, "hello")), ("hello", 1));
            assert_eq!(swap((true, 42u8)), (42u8, true));
        }
    
        #[test]
        fn test_fst_snd() {
            assert_eq!(fst((42, "hello")), 42);
            assert_eq!(snd((42, "hello")), "hello");
        }
    
        #[test]
        fn test_pair_constructor() {
            assert_eq!(pair(1, 2), (1, 2));
            assert_eq!(pair("a", true), ("a", true));
        }
    
        #[test]
        fn test_uncurry() {
            let add_pair = uncurry(|a: i32, b: i32| a + b);
            assert_eq!(add_pair((3, 4)), 7);
            assert_eq!(add_pair((0, 0)), 0);
            assert_eq!(add_pair((-1, 1)), 0);
        }
    
        #[test]
        fn test_curry_roundtrip() {
            let add_pair = uncurry(|a: i32, b: i32| a + b);
            let curried = curry(add_pair);
            assert_eq!(curried(3)(4), 7);
            assert_eq!(curried(10)(5), 15);
            assert_eq!(curried(0)(0), 0);
        }
    
        #[test]
        fn test_distance_free_fn() {
            let origin = Point2d { x: 0.0, y: 0.0 };
            let p = Point2d { x: 3.0, y: 4.0 };
            assert!((distance(&origin, &p) - 5.0).abs() < 1e-10);
        }
    
        #[test]
        fn test_distance_zero() {
            let p = Point2d { x: 1.0, y: 2.0 };
            assert_eq!(distance(&p, &p), 0.0);
        }
    
        #[test]
        fn test_distance_method() {
            let origin = Point2d { x: 0.0, y: 0.0 };
            let p = Point2d { x: 3.0, y: 4.0 };
            assert!((origin.distance_to(&p) - 5.0).abs() < 1e-10);
        }
    
        #[test]
        fn test_point3d_fields() {
            let p = Point3d {
                x: 1.0,
                y: 2.0,
                z: 3.0,
            };
            assert_eq!(p.x, 1.0);
            assert_eq!(p.y, 2.0);
            assert_eq!(p.z, 3.0);
        }
    }

    Deep Comparison

    OCaml vs Rust: Product Types

    Side-by-Side Code

    OCaml

    type point2d = { x: float; y: float }
    
    let swap (a, b) = (b, a)
    let fst (a, _) = a
    let snd (_, b) = b
    let pair a b = (a, b)
    
    let uncurry f (a, b) = f a b
    let curry f a b = f (a, b)
    
    let distance p q =
      let dx = p.x -. q.x and dy = p.y -. q.y in
      sqrt (dx *. dx +. dy *. dy)
    

    Rust (idiomatic — method on struct)

    #[derive(Debug, Clone, Copy, PartialEq)]
    pub struct Point2d { pub x: f64, pub y: f64 }
    
    impl Point2d {
        pub fn distance_to(&self, other: &Self) -> f64 {
            let dx = self.x - other.x;
            let dy = self.y - other.y;
            (dx * dx + dy * dy).sqrt()
        }
    }
    
    pub fn swap<A, B>(pair: (A, B)) -> (B, A) { (pair.1, pair.0) }
    pub fn fst<A, B>(pair: (A, B)) -> A { pair.0 }
    pub fn snd<A, B>(pair: (A, B)) -> B { pair.1 }
    

    Rust (functional — curry/uncurry with Rc)

    use std::rc::Rc;
    
    pub fn uncurry<A, B, C>(f: impl Fn(A, B) -> C) -> impl Fn((A, B)) -> C {
        move |(a, b)| f(a, b)
    }
    
    pub fn curry<A: Clone + 'static, B: 'static, C: 'static>(
        f: impl Fn((A, B)) -> C + 'static,
    ) -> impl Fn(A) -> Box<dyn Fn(B) -> C> {
        let f = Rc::new(f);
        move |a: A| {
            let f = Rc::clone(&f);
            let a = a.clone();
            Box::new(move |b: B| f((a.clone(), b)))
        }
    }
    

    Type Signatures

    ConceptOCamlRust
    Record typetype point2d = { x: float; y: float }struct Point2d { x: f64, y: f64 }
    Tuple swapval swap : ('a * 'b) -> ('b * 'a)fn swap<A,B>(pair: (A,B)) -> (B,A)
    Projectionval fst : ('a * 'b) -> 'afn fst<A,B>(pair: (A,B)) -> A
    Uncurryval uncurry : ('a -> 'b -> 'c) -> 'a * 'b -> 'cfn uncurry<A,B,C>(f: impl Fn(A,B)->C) -> impl Fn((A,B))->C
    Curryval curry : ('a * 'b -> 'c) -> 'a -> 'b -> 'cfn curry<A,B,C>(f: impl Fn((A,B))->C) -> impl Fn(A)->Box<dyn Fn(B)->C>
    Distanceval distance : point2d -> point2d -> floatfn distance(p: &Point2d, q: &Point2d) -> f64

    Key Insights

  • Structs vs records: OCaml records and Rust structs are syntactically similar, but Rust structs are nominal — two structs with identical fields are different types. OCaml records are also nominal, but the structural feel differs because field access doesn't require self.
  • Tuple ownership: OCaml tuples are garbage-collected values; projecting with fst/snd doesn't affect the original. Rust tuples are moved — calling fst((a, b)) consumes the tuple. For Copy types (like integers) this is invisible; for heap types it matters.
  • No automatic currying: Every OCaml function is automatically curried (f a b is (f a) b). Rust has no such mechanism; multi-argument functions take all arguments at once. Simulating currying requires closures, and sharing a closure across repeated calls requires reference counting (Rc) or a Clone bound.
  • Method syntax: OCaml groups functions in modules (module Point2d = struct ... end). Rust groups methods in impl blocks directly on the type, which is generally more discoverable and is the idiomatic style for domain types with associated behaviour.
  • **'static lifetime bounds:** Box<dyn Fn(B) -> C> is implicitly + 'static, which forces A: 'static and the captured f: 'static. In OCaml all values have indefinite lifetime (GC), so this constraint has no analogue.
  • When to Use Each Style

    Use the idiomatic Rust (method) style when: you own a domain type and want behaviour collocated with data — i.e., almost always for structs like Point2d.

    Use the functional (free-function) style when: implementing generic combinators (swap, uncurry, curry) that operate on any pair type and have no natural "owner" type to attach to.

    Exercises

  • Implement bimap for a product type Pair<A, B> that applies one function to the first component and another to the second.
  • Define a generic swap function for product types and implement curry and uncurry as morphisms in the product category.
  • Implement a heterogeneous record type using Rust tuples as a product type and write a lens for each field that allows reading and updating individual components.
  • Open Source Repos