Rank-2 Types Simulation
Tutorial
The Problem
A rank-2 type is a function that takes a polymorphic argument — a function that must work for all types, not just one specific type chosen by the caller. The classic example: runST :: (forall s. ST s a) -> a in Haskell prevents mutable state from leaking out of a computation. In Rust and OCaml, rank-2 polymorphism is simulated using traits with generic methods, which enforce "the callee chooses the type" rather than "the caller chooses the type."
🎯 Learning Outcomes
Code Example
pub trait IdFn {
fn apply<T>(&self, x: T) -> T;
}
pub struct Identity;
impl IdFn for Identity {
fn apply<T>(&self, x: T) -> T { x }
}
// F: IdFn — NOT &dyn IdFn. Traits with generic methods are not dyn-compatible.
pub fn apply_id<F: IdFn>(f: &F) -> (i32, String) {
let x = f.apply(42_i32);
let y = f.apply("hello".to_string());
(x, y)
}Key Differences
'a.); Rust simulates them with trait-based static dispatch.dyn (generic methods prevent it).{ apply = fun x -> x } is compact; Rust requires defining a struct and an impl IdFn block.OCaml Approach
OCaml supports rank-2 types natively via record polymorphism:
type id_fn = { apply : 'a. 'a -> 'a }
let apply_id f = (f.apply 42, f.apply "hello")
let identity = { apply = fun x -> x }
The 'a. prefix in the record field type means "for all 'a". This is more ergonomic than Rust's trait simulation and supports dyn-style usage naturally. OCaml's runST equivalent is straightforward with this mechanism.
Full Source
#![allow(clippy::all)]
//! # Rank-2 Types Simulation in Rust
//!
//! Rust lacks native rank-2 polymorphism, but traits with generic methods
//! serve as an equivalent: a trait bound forces the caller to supply something
//! that works for *every* type the trait method is called with.
//!
//! Crucially, traits with generic methods are **not** dyn-compatible in Rust
//! (they can't be used with `dyn Trait`). Rank-2 is therefore encoded via
//! *static dispatch* — `F: IdFn` — not dynamic dispatch. This is actually
//! the more faithful analogue to OCaml's record-polymorphism and first-class
//! modules.
// ---------------------------------------------------------------------------
// Approach 1: Trait-based rank-2 identity
// ---------------------------------------------------------------------------
// `trait IdFn` is the "for all T" contract. Any implementor must handle every T.
// `apply_id` can therefore call `f.apply` with an i32 *and* a String in one go.
// The caller cannot specialize `apply` to a single type — the callee owns that.
pub trait IdFn {
fn apply<T>(&self, x: T) -> T;
}
pub struct Identity;
impl IdFn for Identity {
fn apply<T>(&self, x: T) -> T {
x
}
}
/// Apply a rank-2 identity function to two different types in a single call.
/// Note: `F: IdFn` (static dispatch) — not `dyn IdFn`, which is not allowed
/// because generic methods are not dyn-compatible.
pub fn apply_id<F: IdFn>(f: &F) -> (i32, String) {
let x = f.apply(42_i32);
let y = f.apply("hello".to_string());
(x, y)
}
// ---------------------------------------------------------------------------
// Approach 2: Rank-2 "for-all" callback with Debug bound
// ---------------------------------------------------------------------------
// The trait carries a generic method, so the implementor must work for any
// `T: fmt::Debug`. This lets `apply_forall` call it with heterogeneous types.
pub trait ForAll {
fn call<T: std::fmt::Debug>(&self, val: T) -> String;
}
pub struct ShowDebug;
impl ForAll for ShowDebug {
fn call<T: std::fmt::Debug>(&self, val: T) -> String {
format!("{val:?}")
}
}
/// Apply a rank-2 "format as debug" function to multiple distinct types.
pub fn apply_forall<F: ForAll>(f: &F) -> Vec<String> {
vec![f.call(42_i32), f.call("world"), f.call(vec![1_u8, 2, 3])]
}
// ---------------------------------------------------------------------------
// Approach 3: Rank-2 via higher-ranked trait bounds (HRTBs)
// ---------------------------------------------------------------------------
// Rust's `for<'a>` syntax is the closest native form of rank-2: a closure
// that must be valid for *every* lifetime, not just one chosen by the caller.
// This is rank-2 over lifetimes. We use it to apply a function to references
// of different-scoped data within a single call.
/// Accept any function that works for *any* lifetime `'a`, apply it to two
/// independently-scoped references, and return both results.
pub fn apply_twice_hrtb<F>(f: F) -> (usize, usize)
where
F: for<'a> Fn(&'a str) -> usize,
{
let result1 = {
let s1 = String::from("hello world");
f(&s1)
};
let result2 = {
let s2 = String::from("rank-2");
f(&s2)
};
(result1, result2)
}
// ---------------------------------------------------------------------------
// Approach 4: ST-monad style — rank-2 prevents state from leaking
// ---------------------------------------------------------------------------
// In Haskell/OCaml the phantom type `s` is existentially quantified at the
// boundary so no `STRef s a` can escape `runST`. In Rust we encode the same
// discipline with a lifetime brand: `s` is an invariant lifetime that the
// closure must be generic over, which the compiler enforces.
//
// The simplified version: a trait that represents a "pure state action"
// whose result type is fixed, so no internal state references can leak.
pub trait StAction {
/// Run the state action and return a result that does not carry `s`.
fn run(&self) -> i32;
}
pub struct CountRef {
pub initial: i32,
}
impl StAction for CountRef {
fn run(&self) -> i32 {
// Simulate: new_ref(initial), increment, read — result escapes, ref doesn't.
let mut val = self.initial;
val += 1;
val
}
}
/// Accept any `StAction` — the result type is fixed (i32), so no internal
/// state can "leak" through the return type.
pub fn run_st<A: StAction>(action: &A) -> i32 {
action.run()
}
// ---------------------------------------------------------------------------
// Approach 5: Apply a polymorphic transformation to a heterogeneous pair
// ---------------------------------------------------------------------------
pub struct HetPair<A, B>(pub A, pub B);
pub trait MapPair<A, B> {
fn map_pair(&self, pair: HetPair<A, B>) -> HetPair<A, B>;
}
pub struct ClonePair;
impl<A: Clone, B: Clone> MapPair<A, B> for ClonePair {
fn map_pair(&self, pair: HetPair<A, B>) -> HetPair<A, B> {
HetPair(pair.0.clone(), pair.1.clone())
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identity_applies_to_two_types() {
let id = Identity;
let (n, s) = apply_id(&id);
assert_eq!(n, 42);
assert_eq!(s, "hello");
}
#[test]
fn test_identity_preserves_value_for_multiple_types() {
let id = Identity;
assert_eq!(id.apply(99_u64), 99_u64);
assert_eq!(id.apply("rust"), "rust");
assert_eq!(id.apply(3.14_f64), 3.14_f64);
}
#[test]
fn test_forall_formats_different_types() {
let show = ShowDebug;
let results = apply_forall(&show);
assert_eq!(results[0], "42");
assert_eq!(results[1], "\"world\"");
assert_eq!(results[2], "[1, 2, 3]");
}
#[test]
fn test_forall_call_directly() {
let show = ShowDebug;
assert_eq!(show.call(true), "true");
assert_eq!(show.call(0_i32), "0");
}
#[test]
fn test_hrtb_applies_to_different_scopes() {
let (a, b) = apply_twice_hrtb(|s| s.len());
assert_eq!(a, 11); // "hello world"
assert_eq!(b, 6); // "rank-2"
}
#[test]
fn test_hrtb_with_word_count() {
let count_words = |s: &str| s.split_whitespace().count();
let (a, b) = apply_twice_hrtb(count_words);
assert_eq!(a, 2); // "hello world" → 2 words
assert_eq!(b, 1); // "rank-2" → 1 word
}
#[test]
fn test_st_action_result_escapes_state_does_not() {
let action = CountRef { initial: 5 };
assert_eq!(run_st(&action), 6);
}
#[test]
fn test_st_action_pure_runs_consistently() {
let action = CountRef { initial: 10 };
// Running twice should return the same pure result each time.
assert_eq!(run_st(&action), run_st(&action));
}
#[test]
fn test_het_pair_clone() {
let mapper = ClonePair;
let pair = HetPair(42_i32, "hello".to_string());
let result = mapper.map_pair(pair);
assert_eq!(result.0, 42);
assert_eq!(result.1, "hello");
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identity_applies_to_two_types() {
let id = Identity;
let (n, s) = apply_id(&id);
assert_eq!(n, 42);
assert_eq!(s, "hello");
}
#[test]
fn test_identity_preserves_value_for_multiple_types() {
let id = Identity;
assert_eq!(id.apply(99_u64), 99_u64);
assert_eq!(id.apply("rust"), "rust");
assert_eq!(id.apply(3.14_f64), 3.14_f64);
}
#[test]
fn test_forall_formats_different_types() {
let show = ShowDebug;
let results = apply_forall(&show);
assert_eq!(results[0], "42");
assert_eq!(results[1], "\"world\"");
assert_eq!(results[2], "[1, 2, 3]");
}
#[test]
fn test_forall_call_directly() {
let show = ShowDebug;
assert_eq!(show.call(true), "true");
assert_eq!(show.call(0_i32), "0");
}
#[test]
fn test_hrtb_applies_to_different_scopes() {
let (a, b) = apply_twice_hrtb(|s| s.len());
assert_eq!(a, 11); // "hello world"
assert_eq!(b, 6); // "rank-2"
}
#[test]
fn test_hrtb_with_word_count() {
let count_words = |s: &str| s.split_whitespace().count();
let (a, b) = apply_twice_hrtb(count_words);
assert_eq!(a, 2); // "hello world" → 2 words
assert_eq!(b, 1); // "rank-2" → 1 word
}
#[test]
fn test_st_action_result_escapes_state_does_not() {
let action = CountRef { initial: 5 };
assert_eq!(run_st(&action), 6);
}
#[test]
fn test_st_action_pure_runs_consistently() {
let action = CountRef { initial: 10 };
// Running twice should return the same pure result each time.
assert_eq!(run_st(&action), run_st(&action));
}
#[test]
fn test_het_pair_clone() {
let mapper = ClonePair;
let pair = HetPair(42_i32, "hello".to_string());
let result = mapper.map_pair(pair);
assert_eq!(result.0, 42);
assert_eq!(result.1, "hello");
}
}
Deep Comparison
OCaml vs Rust: Rank-2 Types
The Core Problem
Rank-2 polymorphism lets you pass a function that must work for any type — not a single type chosen by the caller. In rank-1 (normal) generics, the caller picks the type parameter. In rank-2, the callee picks it (potentially multiple times, with different types in sequence).
Side-by-Side Code
OCaml — record-encoded rank-2
(* Wrap the polymorphic function in a record to get rank-2 *)
type id_fn = { id : 'a. 'a -> 'a }
let apply_id (f : id_fn) =
let x = f.id 42 in (* int *)
let y = f.id "hello" in (* string *)
(x, y)
let () =
let result = apply_id { id = fun x -> x } in
assert (result = (42, "hello"))
OCaml — first-class module rank-2
module type TRANSFORM = sig
val transform : 'a -> 'a
end
let apply_transform (module T : TRANSFORM) =
(T.transform 42, T.transform "hello")
module Identity : TRANSFORM = struct
let transform x = x
end
let () =
let (n, s) = apply_transform (module Identity) in
assert (n = 42 && s = "hello")
Rust — trait with generic method (rank-2 via static dispatch)
pub trait IdFn {
fn apply<T>(&self, x: T) -> T;
}
pub struct Identity;
impl IdFn for Identity {
fn apply<T>(&self, x: T) -> T { x }
}
// F: IdFn — NOT &dyn IdFn. Traits with generic methods are not dyn-compatible.
pub fn apply_id<F: IdFn>(f: &F) -> (i32, String) {
let x = f.apply(42_i32);
let y = f.apply("hello".to_string());
(x, y)
}
Rust — ForAll trait with Debug bound
pub trait ForAll {
fn call<T: std::fmt::Debug>(&self, val: T) -> String;
}
pub struct ShowDebug;
impl ForAll for ShowDebug {
fn call<T: std::fmt::Debug>(&self, val: T) -> String {
format!("{val:?}")
}
}
pub fn apply_forall<F: ForAll>(f: &F) -> Vec<String> {
vec![f.call(42_i32), f.call("world"), f.call(vec![1_u8, 2, 3])]
}
Rust — rank-2 over lifetimes (Higher-Ranked Trait Bounds)
// `for<'a>` is Rust's native rank-2: must work for every lifetime, not one chosen by caller.
pub fn apply_twice_hrtb<F>(f: F) -> (usize, usize)
where
F: for<'a> Fn(&'a str) -> usize,
{
let result1 = { let s1 = String::from("hello world"); f(&s1) };
let result2 = { let s2 = String::from("rank-2"); f(&s2) };
(result1, result2)
}
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Rank-2 type | { id : 'a. 'a -> 'a } | trait IdFn { fn apply<T>(&self, x: T) -> T; } |
| Universal quantifier | 'a. (explicit in record) | Implicit in trait's generic method parameter |
| Encoding mechanism | Record field / first-class module | Trait bound F: IdFn (static dispatch) |
| Dispatch style | Structural (record field, module) | Monomorphisation (zero-cost) |
| Native rank-2 syntax | 'a. in record type | for<'a> in HRTB (lifetime level only) |
Key Insights
Unlike OCaml records, you cannot erase a trait with generic methods into &dyn Trait — the compiler
cannot build a vtable for a method whose concrete type isn't known. Rank-2 in Rust is therefore encoded
via static dispatch (F: IdFn), not dynamic dispatch. This is actually more performant: zero overhead,
monomorphised at compile time.
OCaml annotates a record field with 'a. (an explicit universal quantifier), forcing any record value
to carry a function that works for all 'a. Rust's equivalent is F: IdFn where IdFn has a generic
method — the compiler enforces that F must handle every T the method might be called with.
for<'a> is Rust's native rank-2 — but only over lifetimes.** Rust's higher-ranked trait bounds (for<'a> Fn(&'a str) -> usize) are genuine rank-2: the caller cannot
fix 'a; the callee uses the function with independently-scoped borrows. This mirrors Haskell's runST
pattern and is the language's built-in rank-2 mechanism — though only over lifetimes, not types.
OCaml's (module T : TRANSFORM) passes a module whose transform is universally polymorphic.
Rust's <F: ForAll>(f: &F) is structurally identical: F must implement call<T> for all T,
and the concrete type is resolved at compile time by the monomorphiser.
In Haskell's runST and OCaml's ST simulation, rank-2 prevents mutable state references from
leaking out of a computation. Rust achieves the same with lifetime branding (an invariant phantom
lifetime 's): the type system statically rejects any attempt to let a &'s mut T escape the
closure that created it, without needing rank-2 over types at all.
When to Use Each Style
**Use F: IdFn (generic bound / static dispatch)** when the concrete implementor is always known at
compile time and you want zero-cost monomorphisation — equivalent to OCaml's first-class modules
resolved by the compiler.
**Use for<'a> HRTBs** when you need a closure or function that must be valid for every possible
lifetime — the canonical Rust use case is fn(&T) -> U callbacks in APIs that hold borrows of
varying lifetimes.
Use the ST-lifetime-brand pattern when you need to guarantee that mutable state created inside a scope cannot escape it — a safety property that rank-2 enforces at the type level in both OCaml and Rust.
Exercises
PolyMapper trait with fn map<T, U>(&self, f: impl Fn(T) -> U, x: T) -> U and use it to apply transformations to different types in one call.apply_to_pair<F: IdFn>(f: &F, x: i32, y: String) -> (i32, String) using the same F for both.RunST-like pattern where a computation over a phantom state type S prevents values from escaping their scope.