Product Types
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
struct types with named fieldsuncurry and curry encode the categorical isomorphism (A × B → C) ≅ (A → B → C)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
mut binding to mutate fields.fst/snd unless the type is Copy.Rc for shared state.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);
}
}#[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
| 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
self.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.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.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
bimap for a product type Pair<A, B> that applies one function to the first component and another to the second.swap function for product types and implement curry and uncurry as morphisms in the product category.