Susan Potter
### software  ·  Created  ·  Updated

Profunctors for Practitioners: The API (Part 3)

Mountain reflection in still water
Photo by Jonatan Pie on Unsplash

In Part 2, we built intuition for profunctors: contravariant in the first type parameter, covariant in the second. Part 3 covers the formal API, its laws, and the extensions that make profunctors useful for optics.

The Profunctor Typeclass

A profunctor is a type constructor with two type parameters that supports dimap:

class Profunctor p where
  dimap :: (a -> b) -> (c -> d) -> p b c -> p a d
  lmap  :: (a -> b) -> p b c -> p a c
  rmap  :: (c -> d) -> p b c -> p b d

The dimap operation transforms both ends:

  • First argument a -> b transforms the input (contravariant: notice b to a in the result)
  • Second argument c -> d transforms the output (covariant: c to d in the result)

The lmap and rmap operations transform one end at a time:

lmap f = dimap f id    -- transform input only
rmap g = dimap id g    -- transform output only

Minimal complete definition: either dimap alone, or both lmap and rmap.

The Function Instance

The simplest profunctor is the function arrow:

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

Given h :: b -> c:

lmap f h pre-composes with f, yielding =h . f
a -> c=
rmap g h post-composes with g, yielding =g . h
b -> d=
dimap f g h does both: =g . h . f
a -> d=

Profunctor Laws

Profunctor instances must satisfy two laws:

Identity Law

Mapping with identity functions changes nothing:

dimap id id = id

Composition Law

Sequential dimap calls fuse into one:

dimap f g . dimap f' g' = dimap (f' . f) (g . g')

These laws constrain dimap to behave as expected. The identity law says the profunctor structure is preserved when no transformation occurs. The composition law says the order of applying transformations matches function composition.

Beyond Functions: Other Profunctors

The function arrow is the simplest profunctor, but Part 2's Codec example hints at richer structures. A codec's decode operation can fail, returning Either Error b rather than just b. This pattern appears throughout the paired operations from Part 1: parsing can fail, decryption can fail, deserialization can fail.

The profunctors below capture these patterns. Each emphasizes a different aspect of bidirectional transformation:

Tagged (Phantom Input)

A value that ignores its first type parameter:

newtype Tagged a b = Tagged { unTagged :: b }

instance Profunctor Tagged where
  dimap _ g (Tagged b) = Tagged (g b)

Tagged is covariant in b and ignores a. The lmap operation has no effect since there is no a value to transform.

Star (Kleisli Arrow)

A function returning a functor:

newtype Star f a b = Star { runStar :: a -> f b }

instance Functor f => Profunctor (Star f) where
  dimap f g (Star h) = Star (fmap g . h . f)

Star wraps a -> f b. With f = Maybe, this models partial functions. With f = IO, this models effectful computations. With f = Either Error, this models the fallible decode side of a codec from Part 2.

Forget (Extract a Value)

A function that extracts a value, ignoring the second type parameter:

newtype Forget r a b = Forget { runForget :: a -> r }

instance Profunctor (Forget r) where
  dimap f _ (Forget h) = Forget (h . f)

Forget r is contravariant in a and ignores b. This is useful for extracting values from structures without building new ones.

Strong Profunctors

A Strong profunctor can lift through products (tuples):

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

Given a profunctor that transforms a to b, Strong lets you transform (a, c) to (b, c), leaving the c component untouched.

The function arrow is Strong:

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

Strong Laws

-- first' and second' are symmetric
first' = dimap swap swap . second'
  where swap (a, b) = (b, a)

-- Nesting first' is associative
first' . first' = dimap assoc assoc' . first'
  where
    assoc  ((a, b), c) = (a, (b, c))
    assoc' (a, (b, c)) = ((a, b), c)

-- first' commutes with lmap
lmap (first f) . first' = rmap (first f) . first'
  where first f (a, c) = (f a, c)

Why Strong Matters

Strong profunctors can focus on part of a product while preserving the rest. This is the key capability for lenses:

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

A Lens works for any Strong profunctor. It focuses on the a inside an s, transforming it to b and yielding a t.

Choice Profunctors

A Choice profunctor can lift through sums (Either):

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)

Given a profunctor that transforms a to b, Choice lets you transform Either a c to Either b c, leaving the c branch untouched.

The function arrow is Choice:

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

Choice Laws

-- left' and right' are symmetric
left' = dimap mirror mirror . right'
  where mirror = either Right Left

-- Nesting left' is associative
left' . left' = dimap assoc assoc' . left'
  where
    assoc  (Left (Left a))  = Left a
    assoc  (Left (Right b)) = Right (Left b)
    assoc  (Right c)        = Right (Right c)
    assoc' (Left a)         = Left (Left a)
    assoc' (Right (Left b)) = Left (Right b)
    assoc' (Right (Right c))= Right c

-- left' commutes with lmap
lmap (left f) . left' = rmap (left f) . left'
  where left f = either (Left . f) Right

Why Choice Matters

Choice profunctors can target one branch of a sum while passing through others. This is the key capability for prisms:

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

A Prism works for any Choice profunctor. It targets the a case inside an s, transforming it to b when present.

Combining Strong and Choice

Some profunctors are both Strong and Choice. The function arrow is one:

-- (->) is Strong
first'  f (a, c) = (f a, c)

-- (->) is also Choice
left' f = either (Left . f) Right

Profunctors with both capabilities can express more optics:

OpticConstraintFocus
LensStrongPart of a product
PrismChoiceBranch of a sum
AffineStrong + ChoiceOptional part of a product

Full Code Listing

{-# LANGUAGE RankNTypes #-}

module Profunctors where

-- Core Profunctor
class Profunctor p where
  dimap :: (a -> b) -> (c -> d) -> p b c -> p a d
  lmap  :: (a -> b) -> p b c -> p a c
  rmap  :: (c -> d) -> p b c -> p b d

  lmap f = dimap f id
  rmap g = dimap id g
  dimap f g = lmap f . rmap g

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)

-- Optic type aliases
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

-- Example profunctors
newtype Star f a b = Star { runStar :: a -> f b }

instance Functor f => Profunctor (Star f) where
  dimap f g (Star h) = Star (fmap g . h . f)

instance Functor f => Strong (Star f) where
  first'  (Star f) = Star (\(a, c) -> fmap (\b -> (b, c)) (f a))
  second' (Star f) = Star (\(c, a) -> fmap (\b -> (c, b)) (f a))

newtype Forget r a b = Forget { runForget :: a -> r }

instance Profunctor (Forget r) where
  dimap f _ (Forget h) = Forget (h . f)

instance Monoid r => Choice (Forget r) where
  left'  (Forget f) = Forget (either f (const mempty))
  right' (Forget f) = Forget (either (const mempty) f)

Summary

The Profunctor API provides:

  • dimap: Transform both input and output
  • lmap / rmap: Transform one side
  • Strong: Lift through products (tuples)
  • Choice: Lift through sums (Either)

These primitives compose to build optics, codecs, and other bidirectional abstractions. The laws guarantee consistent behavior, and the type constraints (Strong, Choice) document what capabilities each abstraction requires.

The profunctor approach separates the what (the transformation) from the how (the specific profunctor instance). Library code written against Profunctor, Strong, or Choice works with any conforming type, enabling reuse and composition.

The paired operations from Part 1 (encode/decode, serialize/deserialize, encrypt/decrypt, sign/validate, print/parse) all benefit from this abstraction. A codec built on profunctors can be adapted with dimap, composed with other codecs, and instantiated with different profunctor types depending on whether you need pure functions, fallible operations, or value extraction.