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
- Curry/uncurry for free โ any two-argument function can switch between curried and uncurried form. Partial application follows directly from the curried form.
- Data modeling clarity โ recognising that `struct` fields are a categorical product tells you immediately that all fields must co-exist, ruling out nonsensical "partially constructed" states.
- Dual to sum types โ every struct is a product; every enum is a sum (coproduct). Together they cover all data. Understanding one helps you understand the other.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| 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 projection | Consumes tuple unless `Copy` |
| Curry | Automatic (all OCaml fns are curried) | Explicit closure + `Rc` for sharing |
| Method syntax | Free 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
| Concept | OCaml | Rust |
|---|---|---|
| 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.