🦀 Functional Rust

075: Profunctor — Contramap Input, Map Output

Difficulty: ⭐⭐⭐ Level: Advanced A type `P<A, B>` that you can adapt on both ends — remap the input (contravariant) and remap the output (covariant) — the abstraction that unifies all optic types.

The Problem This Solves

You have a function that does exactly what you need — but its input and output types are wrong for your context. Say you have a function that converts `String → usize` (string to length). Now you need to feed it `i32` instead of `String`, and you want `bool` (is it long?) instead of `usize`. The naive solution: write a new function, or write a wrapper every time:
// You already have this:
fn str_len(s: String) -> usize { s.len() }

// Now you need: i32 -> bool (is digit-representation long?)
// So you write yet another function:
fn int_has_long_repr(n: i32) -> bool {
 str_len(n.to_string()) > 3
}
That's fine for one case. But what if you have dozens of variations? What if you want to build adapter pipelines systematically, or store partially-adapted functions in data structures? You end up with scattered wrapper functions, or you reach for trait objects and lose type information. Neither is elegant. The profunctor abstraction formalises this "adapt both ends" operation as `dimap`, giving you a unified, composable way to wire up transformations. It exists to solve exactly that pain.

The Intuition

A profunctor is any type `P<A, B>` that supports two kinds of mapping: 1. `rmap` (covariant) — adapt the output. If you have `P<A, B>` and a function `B → D`, you get `P<A, D>`. This is like `map` on a regular functor. 2. `lmap` (contravariant) — adapt the input. If you have `P<A, B>` and a function `C → A` (note: going backward), you get `P<C, B>`. This is "pre-compose" — you plug in a converter before the profunctor runs. 3. `dimap` — both at once. `C → A` on the input, `B → D` on the output. Gives you `P<C, D>` from `P<A, B>`. The most natural profunctor is a plain function `fn(A) -> B`: Analogy: Think of a profunctor like a pipe fitting adapter. The pipe does something in the middle. `lmap` adds an adapter at the inlet (converts the incoming type). `rmap` adds an adapter at the outlet (converts the outgoing type). `dimap` does both.
C ──[lmap/pre]──▶ A ──[profunctor]──▶ B ──[rmap/post]──▶ D
    (contravariant)                       (covariant)
The word "contravariant" just means the input adapter runs in the opposite direction of what you'd expect — you provide `C → A` (not `A → C`) to make a `P<C, B>` from `P<A, B>`.

How It Works in Rust

// The core profunctor type: wraps a function A -> B
pub struct Mapper<A, B> {
 f: Box<dyn Fn(A) -> B>,
}

impl<A: 'static, B: 'static> Mapper<A, B> {
 pub fn new<F: Fn(A) -> B + 'static>(f: F) -> Self {
     Mapper { f: Box::new(f) }
 }

 pub fn apply(&self, a: A) -> B {
     (self.f)(a)
 }

 // dimap: adapt BOTH input and output at once
 // pre:  converts new input type C into A (what the function expects)
 // post: converts B (what the function produces) into new output type D
 pub fn dimap<C: 'static, D: 'static>(
     self,
     pre: impl Fn(C) -> A + 'static,
     post: impl Fn(B) -> D + 'static,
 ) -> Mapper<C, D> {
     Mapper::new(move |c| post((self.f)(pre(c))))
     //                     ^^^^ post . self.f . pre
 }

 // lmap: adapt only the INPUT (contravariant) — dimap pre id
 pub fn lmap<C: 'static>(self, pre: impl Fn(C) -> A + 'static) -> Mapper<C, B> {
     Mapper::new(move |c| (self.f)(pre(c)))
 }

 // rmap: adapt only the OUTPUT (covariant) — dimap id post
 pub fn rmap<D: 'static>(self, post: impl Fn(B) -> D + 'static) -> Mapper<A, D> {
     Mapper::new(move |a| post((self.f)(a)))
 }
}

// Example: start with String -> String (uppercase)
let upper = Mapper::new(|s: String| s.to_uppercase());

// lmap: feed it i32 instead of String
let int_upper = Mapper::new(|s: String| s.to_uppercase())
 .lmap(|n: i32| n.to_string());
// int_upper: i32 -> String
int_upper.apply(42);  // "42"

// rmap: get length instead of String
let upper_len = Mapper::new(|s: String| s.to_uppercase())
 .rmap(|s: String| s.len());
// upper_len: String -> usize
upper_len.apply("hello".to_string());  // 5

// dimap: i32 in, length out
let int_upper_len = Mapper::new(|s: String| s.to_uppercase())
 .dimap(|n: i32| n.to_string(), |s: String| s.len());
// int_upper_len: i32 -> usize
int_upper_len.apply(42);  // 2  ("42".to_uppercase().len())

// Star: a profunctor whose output is wrapped in Option
// Useful for fallible transformations
pub struct Star<A, B> {
 run: Box<dyn Fn(A) -> Option<B>>,
}
// lmap/rmap work the same way — rmap uses .map() on the Option
let parse_int = Star::new(|s: String| s.parse::<i32>().ok());
let parse_double = parse_int.rmap(|n| n * 2);
parse_double.apply("21".to_string());  // Some(42)
parse_double.apply("x".to_string());   // None

What This Unlocks

Key Differences

ConceptOCamlRust
Profunctor traitType class `Profunctor` with `dimap`Trait with `dimap` method (GAT limitations apply)
Function as profunctorNatural: `(->) a b` is a profunctor instanceExplicit wrapper `Mapper<A, B>` around `Box<dyn Fn(A)->B>`
`lmap` / `rmap`Derived from `dimap` in the type classImplemented as separate methods for ergonomics
Star (lifted profunctor)`Star f a b = Kleisli f a b` via `newtype``Star<A, B>` struct wrapping `Box<dyn Fn(A)->Option<B>>`
HKT / polymorphismFull higher-kinded types via functorsNo HKT; must specialise per concrete wrapper type
// Profunctor: contravariant in input, covariant in output.
//
// A profunctor `p a b` supports:
//   dimap :: (c -> a) -> (b -> d) -> p a b -> p c d
//
// Functions `a -> b` are the classic example:
//   dimap f g p  =  g . p . f   ("adapt input with f, output with g")
//
// Rust can't express full HKT profunctors, but we show the concept
// with a concrete `Mapper<A, B>` struct + dimap method.

// ── Concrete Mapper ──────────────────────────────────────────────────────────

pub struct Mapper<A, B> {
    f: Box<dyn Fn(A) -> B>,
}

impl<A: 'static, B: 'static> Mapper<A, B> {
    pub fn new<F: Fn(A) -> B + 'static>(f: F) -> Self {
        Mapper { f: Box::new(f) }
    }

    pub fn apply(&self, a: A) -> B {
        (self.f)(a)
    }

    /// dimap: pre-compose with `pre` (contramap input), post-compose with `post` (map output).
    /// dimap f g p = post ∘ p ∘ pre
    pub fn dimap<C: 'static, D: 'static>(
        self,
        pre: impl Fn(C) -> A + 'static,
        post: impl Fn(B) -> D + 'static,
    ) -> Mapper<C, D> {
        Mapper::new(move |c| post((self.f)(pre(c))))
    }

    /// lmap: adapt only the input (contramap) — dimap f id
    pub fn lmap<C: 'static>(self, pre: impl Fn(C) -> A + 'static) -> Mapper<C, B> {
        Mapper::new(move |c| (self.f)(pre(c)))
    }

    /// rmap: adapt only the output (covariant map) — dimap id g
    pub fn rmap<D: 'static>(self, post: impl Fn(B) -> D + 'static) -> Mapper<A, D> {
        Mapper::new(move |a| post((self.f)(a)))
    }
}

// ── Star: Mapper lifted into a context ──────────────────────────────────────
// Star f a b = a -> f b   (like Mapper but output is wrapped)
// Demonstrates the same dimap pattern in a richer context.

pub struct Star<A, B> {
    run: Box<dyn Fn(A) -> Option<B>>,
}

impl<A: 'static, B: 'static> Star<A, B> {
    pub fn new<F: Fn(A) -> Option<B> + 'static>(f: F) -> Self {
        Star { run: Box::new(f) }
    }

    pub fn apply(&self, a: A) -> Option<B> {
        (self.run)(a)
    }

    pub fn lmap<C: 'static>(self, pre: impl Fn(C) -> A + 'static) -> Star<C, B> {
        Star::new(move |c| (self.run)(pre(c)))
    }

    pub fn rmap<D: 'static>(self, post: impl Fn(B) -> D + 'static) -> Star<A, D> {
        Star::new(move |a| (self.run)(a).map(|b| post(b)))
    }
}

fn main() {
    // A simple string-processing function
    let upper = Mapper::new(|s: String| s.to_uppercase());

    // lmap: adapt input (i32 -> String, then uppercase)
    let int_upper = Mapper::new(|s: String| s.to_uppercase())
        .lmap(|n: i32| n.to_string());
    println!("lmap int->string->upper: {}", int_upper.apply(42));

    // rmap: adapt output (uppercase -> length)
    let upper_len = Mapper::new(|s: String| s.to_uppercase())
        .rmap(|s: String| s.len());
    println!("rmap string->upper->len: {}", upper_len.apply("hello".to_string()));

    // dimap: adapt both
    let int_upper_len = Mapper::new(|s: String| s.to_uppercase())
        .dimap(|n: i32| n.to_string(), |s: String| s.len());
    println!("dimap int->string->upper->len: {}", int_upper_len.apply(42));

    // Profunctor identity law: dimap id id = id
    let p = Mapper::new(|s: String| s.to_uppercase());
    assert_eq!(p.apply("hello".to_string()), upper.apply("hello".to_string()));
    println!("identity law holds");

    // Star profunctor: parse string -> int, then double
    let parse_int = Star::new(|s: String| s.parse::<i32>().ok());
    let parse_double = parse_int.rmap(|n| n * 2);
    println!("Star parse+double: {:?}", parse_double.apply("21".to_string()));
    println!("Star parse+double invalid: {:?}", parse_double.apply("x".to_string()));

    // Compose pipeline with dimap
    // Read a filename, look up its content length (simulated)
    let lookup = Mapper::new(|name: &'static str| match name {
        "readme" => 1024,
        "main"   => 512,
        _        => 0,
    });
    let pipeline = lookup
        .lmap(|s: String| Box::leak(s.into_boxed_str()) as &'static str)
        .rmap(|bytes: usize| format!("{} bytes", bytes));
    println!("pipeline: {}", pipeline.apply("readme".to_string()));
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_lmap() {
        let m = Mapper::new(|s: String| s.len())
            .lmap(|n: i32| n.to_string());
        // 42.to_string() = "42", len = 2
        assert_eq!(m.apply(42), 2);
    }

    #[test]
    fn test_rmap() {
        let m = Mapper::new(|s: String| s.to_uppercase())
            .rmap(|s: String| s.len());
        assert_eq!(m.apply("hello".to_string()), 5);
    }

    #[test]
    fn test_dimap() {
        // dimap (to_string) (len) (to_uppercase)
        // 7 -> "7" -> "7" -> 1
        let m = Mapper::new(|s: String| s.to_uppercase())
            .dimap(|n: i32| n.to_string(), |s: String| s.len());
        assert_eq!(m.apply(7), 1);
    }

    #[test]
    fn test_profunctor_identity_law() {
        // dimap id id p = p
        let p1 = Mapper::new(|x: i32| x * 2);
        let p2 = Mapper::new(|x: i32| x * 2).dimap(|x| x, |x| x);
        assert_eq!(p1.apply(21), p2.apply(21));
    }

    #[test]
    fn test_star_lmap_rmap() {
        let parse = Star::new(|s: String| s.parse::<i32>().ok())
            .rmap(|n| n + 10);
        assert_eq!(parse.apply("5".to_string()), Some(15));
        assert_eq!(parse.apply("bad".to_string()), None);
    }
}
(* Profunctor: a type p a b that is:
   - Contravariant in a (input): dimap maps "backwards" on input
   - Covariant in b (output): maps forwards on output
   Classic example: functions 'a -> 'b *)

(* dimap f g p = g . p . f — adapt input with f, output with g *)
let dimap (f : 'c -> 'a) (g : 'b -> 'd) (p : 'a -> 'b) : 'c -> 'd =
  fun c -> g (p (f c))

(* Convenience: map only input or output *)
let lmap f p = dimap f (fun x -> x) p  (* contramap on input *)
let rmap g p = dimap (fun x -> x) g p  (* map on output *)

let () =
  (* A simple string-processing function *)
  let upper : string -> string = String.uppercase_ascii in

  (* lmap: adapt the input (int -> string, then uppercase) *)
  let int_upper = lmap string_of_int upper in
  Printf.printf "lmap int->string->upper: %s\n" (int_upper 42);

  (* rmap: adapt the output *)
  let upper_len = rmap String.length upper in
  Printf.printf "rmap string->upper->len: %d\n" (upper_len "hello");

  (* dimap: adapt both *)
  let int_upper_len = dimap string_of_int String.length upper in
  Printf.printf "dimap int->string->upper->len: %d\n" (int_upper_len 42);

  (* Profunctor identity law: dimap id id = id *)
  let id x = x in
  let p = dimap id id upper in
  assert (p "hello" = upper "hello");
  Printf.printf "identity law holds\n"