
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.