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.

Susan Potter

Susan Potter

Quant

Work with me

I spent the first half of my career building risk models and market data infrastructure at BNP Paribas, Bank of America, and Citadel, then fourteen years shipping production systems at scale. Now I bring both sides to quantitative trading. If you're a trading firm, family office, or fund looking to tighten the connection between your research ideas and your production trading systems, whether that's building validation pipelines, formalizing signal logic, or getting microstructure analytics into a deployable state, I'd like to hear what you're working on. Reach me at me@susanpotter.net.