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 bBut 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 gRead 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 tA 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 = idThis 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 idYoneda 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