Susan Potter
### snippets  ·  Created  ·  Updated

Profunctors: The Machinery Behind Optics

If you've used lenses or prisms in Haskell, Scala, or PureScript, you've used profunctors, whether you knew it or not. This article unpacks what profunctors are, why they matter, and how they enable the elegant composition of optics.

From Functors to Profunctors

A Functor lets you transform the output of a container. Given f a and a function a -> b, you get f b. The transformation only goes one way: you map over the output.

class Functor f where
  fmap :: (a -> b) -> f a -> f b

But what if you have something with both an input and an output, like a function a -> b? You might want to transform the input (with a function going the other direction) and the output. That's a profunctor.

class Profunctor p where
  dimap :: (a' -> a) -> (b -> b') -> p a b -> p a' b'

The key insight: dimap takes a function that goes "backwards" for the input (a' -> a) and "forwards" for the output (b -> b'). This asymmetry is what makes profunctors contravariant in the first argument and covariant in the second.

The simplest profunctor is the function arrow itself:

instance Profunctor (->) where
  dimap f g h = g . h . f
  -- Pre-compose with f, post-compose with g

Read this as: given a function h :: a -> b, we can create a new function a' -> b' by first applying f to convert a' to a, then h, then g to convert b to b'.

Why Profunctors Matter: The Optics Connection

Lenses and prisms are defined in terms of profunctors. Here's the key insight:

type Lens s t a b  = forall p. Strong p  => p a b -> p s t
type Prism s t a b = forall p. Choice p  => p a b -> p s t

A Lens works for any profunctor that is Strong. A Prism works for any profunctor that has Choice. The different capabilities of profunctors determine what kind of optic you can build.

Strong Profunctors: Working with Products

A Strong profunctor can lift an operation through a product (tuple). If you can transform a to b, you can transform (a, c) to (b, c), leaving the c untouched.

class Profunctor p => Strong p where
  first'  :: p a b -> p (a, c) (b, c)
  second' :: p a b -> p (c, a) (c, b)

instance Strong (->) where
  first'  f (a, c) = (f a, c)
  second' f (c, a) = (c, f a)

This is exactly what lenses need: focus on one part of a structure while carrying along the rest unchanged.

The laws ensure these operations respect the product structure:

-- Nesting two first' operations associates correctly
first' . first' = dimap assoc assoc' . first'
  where
    assoc  :: ((a, b), c) -> (a, (b, c))
    assoc' :: (a, (b, c)) -> ((a, b), c)

Choice Profunctors: Working with Sums

A Choice profunctor lifts through sums (Either). If you can transform a to b, you can transform Either a c to Either b c.

class Profunctor p => Choice p where
  left'  :: p a b -> p (Either a c) (Either b c)
  right' :: p a b -> p (Either c a) (Either c b)

instance Choice (->) where
  left'  f = either (Left . f) Right
  right' f = either Left (Right . f)

This is what prisms need: handle one variant of a sum type while passing through the others.

Monoidal Profunctors: Parallel Composition

Strong lets you pass through extra context unchanged. Monoidal profunctors go further: they can run two transformations in parallel on a product:

class Profunctor p => Monoidal p where
  par   :: p a b -> p c d -> p (a, c) (b, d)
  empty :: p () ()

instance Monoidal (->) where
  par f g (a, c) = (f a, g c)
  empty = id

This enables combining independent optics to work on multiple parts of a structure simultaneously.

Natural Transformations: Polymorphic Functions

Profunctors generalize functors. To complete the picture, we need natural transformations: polymorphic functions that work uniformly across a type constructor.

type f ~> g = forall x. f x -> g x

safeHead :: [] ~> Maybe
safeHead []    = Nothing
safeHead (a:_) = Just a

singletonList :: Maybe ~> []
singletonList Nothing  = []
singletonList (Just a) = [a]

The forall x ensures the transformation cannot inspect the element type. It must work structurally, which guarantees parametricity.

The Yoneda Lemma: Representation Matters

The Yoneda lemma underpins many profunctor optics optimizations. It says that a functor's values can be represented as natural transformations:

newtype Yoneda f a = Yoneda {
  runYoneda :: forall x. (a -> x) -> f x
}

toYoneda :: Functor f => f a -> Yoneda f a
toYoneda fa = Yoneda (\k -> fmap k fa)

fromYoneda :: Yoneda f a -> f a
fromYoneda (Yoneda y) = y id

Yoneda f a is isomorphic to f a. The practical benefit: Yoneda fuses multiple fmap calls into one, which can improve performance in some contexts.

Bringing It Together

Profunctors provide the vocabulary for optics because they capture the essential pattern: transformations that work on both sides of a structure. The hierarchy of profunctor classes (Strong, Choice, Monoidal) corresponds directly to what different optics can do.

When you use a lens to focus on the first element of a tuple, the machinery underneath selects a Strong profunctor instance. When you compose lenses, profunctor composition handles the plumbing automatically.

Understanding profunctors is not required to use optics effectively. But if you want to extend the optics library, define custom optics, or understand why the types work the way they do, profunctors are the foundation.

Full Code Listing

{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE TypeOperators #-}

module Profunctors where

import Data.Either (either)

-- Core Profunctor
class Profunctor (p :: * -> * -> *) where
  dimap :: (a' -> a) -> (b -> b') -> p a b -> p a' b'

instance Profunctor (->) where
  dimap f g h = g . h . f

-- Strong: products
class Profunctor p => Strong p where
  first'  :: p a b -> p (a, c) (b, c)
  second' :: p a b -> p (c, a) (c, b)

instance Strong (->) where
  first'  f (a, c) = (f a, c)
  second' f (c, a) = (c, f a)

-- Choice: sums
class Profunctor p => Choice p where
  left'  :: p a b -> p (Either a c) (Either b c)
  right' :: p a b -> p (Either c a) (Either c b)

instance Choice (->) where
  left'  f = either (Left . f) Right
  right' f = either Left (Right . f)

-- Monoidal: parallel composition
class Profunctor p => Monoidal p where
  par   :: p a b -> p c d -> p (a, c) (b, d)
  empty :: p () ()

instance Monoidal (->) where
  par f g (a, c) = (f a, g c)
  empty = id

-- Optics as profunctor transformers
type Lens s t a b  = forall p. Strong p => p a b -> p s t
type Prism s t a b = forall p. Choice p => p a b -> p s t

-- Natural transformations
type f ~> g = forall x. f x -> g x

-- Yoneda
newtype Yoneda f a = Yoneda { runYoneda :: forall x. (a -> x) -> f x }

toYoneda :: Functor f => f a -> Yoneda f a
toYoneda fa = Yoneda (\k -> fmap k fa)

fromYoneda :: Yoneda f a -> f a
fromYoneda (Yoneda y) = y id