
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 -> bandb -> c, producea -> c - Input transformation: Given
a -> bandz -> a, producez -> 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 xsGiven 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 bFor 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 isNonEmptyNotice 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 bExamples 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:
| Direction | Typeclass | Operation | Flow |
|---|---|---|---|
| Output | Functor | fmap | a -> b lifts to f a -> f b |
| Input | Contravariant | contramap | b -> 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:
| Operation | Consumes | Produces |
|---|---|---|
| encode | structured data | raw bytes |
| decode | raw bytes | structured data |
| serialize | domain object | JSON |
| deserialize | JSON | domain object |
| encrypt | plaintext | ciphertext |
| decrypt | ciphertext | plaintext |
| sign | content | signature |
| validate | signature + content | verification result |
| structure | text | |
| parse | text | structure |
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 userCodecThe 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
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.