ExamplesBy LevelBy TopicLearning Paths
949 Expert

949 Profunctor Intro

Functional Programming

Tutorial

The Problem

Introduce profunctors — abstractions that are covariant in their output type and contravariant in their input type. Implement a concrete Mapper<A, B> struct (wrapping A -> B) with dimap, lmap (contramap input), and rmap (covariant map output). Also implement Star<A, B> (wrapping A -> Option<B>) to show the same profunctor pattern in a richer context.

🎯 Learning Outcomes

  • • Understand the profunctor interface: dimap :: (C -> A) -> (B -> D) -> p A B -> p C D
  • • Recognize that dimap f g p = g ∘ p ∘ f — pre-compose input adapter, post-compose output adapter
  • • Implement lmap as dimap f id (contramap — adapt only the input)
  • • Implement rmap as dimap id g (covariant map — adapt only the output)
  • • Understand why Rust cannot express a generic Profunctor trait (no HKT) and how to work around it with concrete types
  • Code Example

    #![allow(clippy::all)]
    // 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)))
        }
    }
    
    #[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);
        }
    }

    Key Differences

    AspectRustOCaml
    Generic profunctor traitNot possible without HKTmodule type PROFUNCTOR with type parameter
    Concrete implementationMapper<A,B> with methodsModule implementing the signature
    Type erasureBox<dyn Fn>Not needed — closures are GC values
    CompositionMethod chaining: .lmap(...).rmap(...)Function application: dimap pre post f
    LawsTested with concrete valuesProvable from module abstraction

    Profunctors generalize the idea of "things that can be mapped on both ends." They appear in optics (lenses/prisms), arrow composition, and parser combinators. The concrete Mapper is the simplest example.

    OCaml Approach

    (* Haskell: class Profunctor p where
         dimap :: (c -> a) -> (b -> d) -> p a b -> p c d
       OCaml can express this with modules and functors *)
    
    module type PROFUNCTOR = sig
      type ('a, 'b) t
      val dimap : ('c -> 'a) -> ('b -> 'd) -> ('a, 'b) t -> ('c, 'd) t
    end
    
    (* Functions are the canonical profunctor *)
    module FnProfunctor : PROFUNCTOR with type ('a, 'b) t = 'a -> 'b = struct
      type ('a, 'b) t = 'a -> 'b
      let dimap pre post f = fun c -> post (f (pre c))
    end
    
    (* Using it *)
    let double_string =
      FnProfunctor.dimap
        int_of_string      (* pre: string -> int *)
        string_of_int      (* post: int -> string *)
        (fun n -> n * 2)   (* core: int -> int *)
    (* double_string "21" = "42" *)
    

    OCaml modules and functors allow a generic PROFUNCTOR interface. The implementation is more composable than Rust's concrete type approach, but requires more boilerplate for module instantiation.

    Full Source

    #![allow(clippy::all)]
    // 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)))
        }
    }
    
    #[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);
        }
    }
    ✓ Tests Rust test suite
    #[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);
        }
    }

    Exercises

  • Verify the profunctor identity law: dimap id id p = p — applying both identity functions leaves the mapper unchanged.
  • Verify the composition law: dimap (f ∘ g) (h ∘ k) = dimap g h ∘ dimap f k.
  • Implement Star fully: Star<A, B> wrapping Fn(A) -> Option<B> with its own dimap.
  • Implement a Costar<A, B> wrapping Fn(Vec<A>) -> B> (works over the input collection).
  • Build a data validation pipeline using Mapper where lmap converts raw string input and rmap formats the validated output.
  • Open Source Repos