๐Ÿฆ€ Functional Rust

231: Product Types

Difficulty: โญโญ Level: Type System / Functional Patterns A product type bundles ALL its fields together simultaneously โ€” it is the categorical product of types, and every struct or tuple in Rust is one.

The Problem This Solves

Data modeling in programming often comes down to two questions: does a value contain ALL of these things at once, or EXACTLY ONE of these things? Product types answer the first question. They're the data structure for "I need both A and B" โ€” a `Point2d` needs both `x` and `y`, a `Config` needs both `host` and `port`, a `KeyValue` needs both key and value. Understanding product types categorically unlocks the `curry`/`uncurry` isomorphism: a function `(A, B) -> C` is provably equivalent to `A -> (B -> C)`. This is why currying is a lossless transformation โ€” not just a convention, but a theorem. It also explains why `struct` and tuples feel interchangeable for computation, even though one is named and one is positional.

The Intuition

A product type contains ALL of its components simultaneously. A tuple `(i32, String)` always has both the `i32` and the `String`. A struct `Point2d { x: f64, y: f64 }` always has both `x` and `y`. You can't have a `Point2d` without a `y`. The categorical view: the product of types `A` and `B` is the type `(A, B)` equipped with two projections `fst: (A, B) -> A` and `snd: (A, B) -> B`. It satisfies a universal property: for any type `X` with functions `f: X -> A` and `g: X -> B`, there's a unique `h: X -> (A, B)` such that `fst(h(x)) = f(x)` and `snd(h(x)) = g(x)`. In code: `h(x) = (f(x), g(x))`. The curry/uncurry isomorphism says: `(A ร— B โ†’ C) โ‰… (A โ†’ B โ†’ C)`. These are two ways to write the same function. `uncurry` collapses the two-argument form to tuple form; `curry` splits the tuple form into the nested function form. They are inverses of each other. Why does this matter? Curried functions compose and partial-apply cleanly. Uncurried functions work naturally with pairs. Both representations are equally expressive โ€” choose whichever fits the context.

How It Works in Rust

// Product type: both fields always present
#[derive(Debug, Clone, Copy)]
pub struct Point2d { pub x: f64, pub y: f64 }

// Projections: extract components from the product
pub fn fst<A, B>(pair: (A, B)) -> A { pair.0 }
pub fn snd<A, B>(pair: (A, B)) -> B { pair.1 }

// Universal property: construct a product from two functions
pub fn pair_of<X: Clone, A, B>(
 f: impl Fn(X) -> A,
 g: impl Fn(X) -> B,
) -> impl Fn(X) -> (A, B) {
 move |x| (f(x.clone()), g(x))  // applies both f and g, bundles results
}

// Uncurry: (A, B) -> C  from  A -> B -> C
pub fn uncurry<A, B, C>(f: impl Fn(A, B) -> C) -> impl Fn((A, B)) -> C {
 move |(a, b)| f(a, b)
}

// Curry: A -> (B -> C)  from  (A, B) -> C
// Requires Rc because the inner closure needs to share `f`
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);
     Box::new(move |b: B| f((a.clone(), b)))
 }
}
Example: `pair_of` constructs a product from two projections
// Given any X, compute both f(x) and g(x) and bundle them
let fanout = pair_of(|x: i32| x * 2, |x: i32| x + 1);
assert_eq!(fanout(5), (10, 6));  // both computed from same input

// Curry/uncurry round-trip
let add = |a: i32, b: i32| a + b;
let uncurried = uncurry(add);
assert_eq!(uncurried((3, 4)), 7);

What This Unlocks

Key Differences

ConceptOCamlRust
Named product`type t = { x: float; y: float }``struct Point2d { x: f64, y: f64 }`
Tuple`(a, b)` โ€” persistent pair`(A, B)` โ€” moved on use (unless `Copy`)
`fst`/`snd`Free projectionConsumes tuple unless `Copy`
CurryAutomatic (all OCaml fns are curried)Explicit closure + `Rc` for sharing
Method syntaxFree functions in module`impl Point2d { fn method(&self) }`
// 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 ---

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 ---

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)))
    }
}

// --- Distance ---

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()
}

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()
    }
}

fn main() {
    // Record (struct) product type
    let p = Point2d { x: 0.0, y: 0.0 };
    let q = Point2d { x: 3.0, y: 4.0 };
    println!("distance (free fn) = {:.1}", distance(&p, &q));
    println!("distance (method)  = {:.1}", p.distance_to(&q));

    // Tuple product type
    let t = (42, "hello");
    println!("fst={} snd={}", fst(t), snd(t));
    println!("swap: snd of swap = {}", snd(swap((42, "hello"))));

    // pair constructor
    let constructed = pair(10, 20);
    println!("pair(10, 20) = {:?}", constructed);

    // uncurry
    let add_pair = uncurry(|a: i32, b: i32| a + b);
    println!("uncurry (+) (3,4) = {}", add_pair((3, 4)));

    // curry
    let curried_add = curry(uncurry(|a: i32, b: i32| a + b));
    println!("curry: 3 + 4 = {}", curried_add(3)(4));

    // Point3d
    let p3 = Point3d { x: 1.0, y: 2.0, z: 3.0 };
    println!("Point3d = {:?}", p3);
}

/* Output:
   distance (free fn) = 5.0
   distance (method)  = 5.0
   fst=42 snd=hello
   swap: snd of swap = 42
   pair(10, 20) = (10, 20)
   uncurry (+) (3,4) = 7
   curry: 3 + 4 = 7
   Point3d = Point3d { x: 1.0, y: 2.0, z: 3.0 }
*/

#[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);
    }
}
(* Product types: combine multiple types into one.
   In category theory, the categorical product. *)

(* Record product type *)
type point2d = { x: float; y: float }
type point3d = { x: float; y: float; z: float }

(* Product as tuple *)
let swap (a, b) = (b, a)
let fst (a, _) = a
let snd (_, b) = b

(* Pair as a product with projections *)
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)

let () =
  let p = { x = 0.; y = 0. } and q = { x = 3.; y = 4. } in
  Printf.printf "distance = %.1f\n" (distance p q);

  let pair = (42, "hello") in
  Printf.printf "fst=%d snd=%s\n" (fst pair) (snd pair);
  Printf.printf "swap: snd of swap = %d\n" (snd (swap pair));

  (* curry/uncurry *)
  let add_pair = uncurry ( + ) in
  Printf.printf "uncurry (+) (3,4) = %d\n" (add_pair (3, 4));
  Printf.printf "curry: %d\n" (curry add_pair 3 4)

๐Ÿ“Š Detailed Comparison

OCaml vs Rust: Product Types

Side-by-Side Code

OCaml

๐Ÿช Show OCaml equivalent
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 type`type point2d = { x: float; y: float }``struct Point2d { x: f64, y: f64 }`
Tuple swap`val swap : ('a 'b) -> ('b 'a)``fn swap<A,B>(pair: (A,B)) -> (B,A)`
Projection`val fst : ('a 'b) -> 'a``fn fst<A,B>(pair: (A,B)) -> A`
Uncurry`val uncurry : ('a -> 'b -> 'c) -> 'a 'b -> 'c``fn uncurry<A,B,C>(f: impl Fn(A,B)->C) -> impl Fn((A,B))->C`
Curry`val curry : ('a * 'b -> 'c) -> 'a -> 'b -> 'c``fn curry<A,B,C>(f: impl Fn((A,B))->C) -> impl Fn(A)->Box<dyn Fn(B)->C>`
Distance`val distance : point2d -> point2d -> float``fn distance(p: &Point2d, q: &Point2d) -> f64`

Key Insights

1. 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`.

2. 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.

3. 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.

4. 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.

5. `'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.