Susan Potter
### software  ·  Created  ·  Updated

Profunctors for Practitioners: First Intuitions (Part 2)

Leaf half green, half withered, representing duality
Photo by Mario Dobelmann on Unsplash

In Part 1, we saw paired operations: encode/decode, serialize/deserialize, encrypt/decrypt, sign/validate, print/parse. Each pair transforms data in opposite directions. Part 2 explores the structure behind these pairs, building toward the Profunctor abstraction.

Two Directions of Transformation

Consider a function a -> b. It has an input (a) and an output (b). When we transform this function, we can work on either end:

  • Output transformation: Given a -> b and b -> c, produce a -> c
  • Input transformation: Given a -> b and z -> a, produce z -> b

These two directions have different characteristics. Output transformation composes forward (b -> c comes after). Input transformation composes backward (z -> a comes before).

Covariant Functors: Transforming Outputs

A covariant functor lets you transform the "inside" of a structure. The classic example is map on lists:

map :: (a -> b) -> [a] -> [b]
map f []     = []
map f (x:xs) = f x : map f xs

Given a function a -> b, you transform a list of a values into a list of b values. The transformation flows in the same direction as the function: a to b.

The Functor typeclass generalizes this:

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

For any structure f that is a Functor, you can transform its contents. The function a -> b becomes f a -> f b. Direction preserved.

Examples from Part 1:

  • Decoding produces structured data from raw bytes
  • Deserializing produces domain objects from JSON
  • Decrypting produces plaintext from ciphertext

Each takes an input representation and produces an output. The output type is what we care about; we transform it further with fmap.

Contravariant Functors: Transforming Inputs

Some structures work in the opposite direction. Consider a predicate:

newtype Predicate a = Predicate { runPredicate :: a -> Bool }

A Predicate a consumes values of type a and produces Bool. If you have a Predicate String (checks if a string is valid), how do you create a Predicate User (checks if a user is valid)?

You need a function User -> String to extract the string from the user:

contramap :: (b -> a) -> Predicate a -> Predicate b
contramap f (Predicate p) = Predicate (p . f)

-- Example
isNonEmpty :: Predicate String
isNonEmpty = Predicate (not . null)

hasUsername :: Predicate User
hasUsername = contramap username isNonEmpty

Notice the direction: b -> a transforms Predicate a to Predicate b. The function goes "backward" relative to the type parameter. This is contravariance.

The Contravariant typeclass:

class Contravariant f where
  contramap :: (b -> a) -> f a -> f b

Examples from Part 1:

  • Encoding consumes structured data, produces raw bytes
  • Serializing consumes domain objects, produces JSON
  • Encrypting consumes plaintext, produces ciphertext

Each takes a domain value and consumes it to produce a fixed output format. The input type is what varies; we transform it with contramap.

The Asymmetry

Covariant and contravariant functors handle opposite ends of a computation:

DirectionTypeclassOperationFlow
OutputFunctorfmapa -> b lifts to f a -> f b
InputContravariantcontramapb -> a lifts to f a -> f b

A Functor transforms what a computation produces. A Contravariant functor transforms what a computation consumes.

The paired operations from Part 1 involve both:

OperationConsumesProduces
encodestructured dataraw bytes
decoderaw bytesstructured data
serializedomain objectJSON
deserializeJSONdomain object
encryptplaintextciphertext
decryptciphertextplaintext
signcontentsignature
validatesignature + contentverification result
printstructuretext
parsetextstructure

Encoding, serializing, encrypting, signing, and printing all consume a domain value (contravariant in their input). Decoding, deserializing, decrypting, validating, and parsing all produce a domain value (covariant in their output). Each pair represents two sides of the same transformation.

Profunctors: Both Directions at Once

A profunctor handles both input and output transformation at once. The simplest profunctor is the function arrow itself:

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

The dimap function takes:

  • a' -> a: transforms the input (contravariant, backward)
  • b -> b': transforms the output (covariant, forward)
  • p a b: the original profunctor value
  • Returns p a' b': the transformed profunctor value

For functions:

instance Profunctor (->) where
  dimap f g h = g . h . f
  -- f transforms input (before h)
  -- g transforms output (after h)

Given h :: a -> b, f :: a' -> a, and g :: b -> b', we get g . h . f :: a' -> b'.

Codecs as Profunctors

The paired operations from Part 1 fit the profunctor pattern. Consider a codec where a is the type you encode and b is the type you decode to:

data Codec a b = Codec
  { encode :: a -> ByteString
  , decode :: ByteString -> Either Error b
  }

The type Codec a b is contravariant in a (the encode input) and covariant in b (the decode output). This makes it a profunctor:

instance Profunctor Codec where
  dimap f g (Codec enc dec) = Codec
    { encode = enc . f          -- transform input before encoding
    , decode = fmap g . dec     -- transform output after decoding
    }

With dimap, you adapt a Codec to work with different types without rewriting the encoding/decoding logic:

-- A codec that encodes/decodes User values
userCodec :: Codec User User

-- Adapt: encode from Request, decode to Response
-- extractUser :: Request -> User
-- toResponse  :: User -> Response
requestCodec :: Codec Request Response
requestCodec = dimap extractUser toResponse userCodec

The dimap operation composes. Chain transformations on both ends to build complex codecs from simple ones.

Why This Matters

Profunctors provide a uniform interface for bidirectional transformations. Instead of writing separate encode and decode functions for every type combination, you write one codec and use dimap to adapt it.

Benefits:

  • Composability: Chain transformations without rewriting core logic
  • Type safety: The compiler ensures input and output transformations align
  • Abstraction: Library code works with any Profunctor, not specific implementations

The paired operations from Part 1 all fit this pattern. Serialization libraries like aeson use profunctor-like structures under the hood. Optics libraries (lenses, prisms) are built on profunctors.

Next

In Part 3, we examine the Profunctor typeclass in detail: its laws, the Strong and Choice extensions, and how these building blocks power optics libraries.