Skip to main content

Learn Contravariant in 5 minutes

Functor

Early into learning Haskell, we’re introduced to the Functor typeclass and how to fmap our way around with it. fmap is a structure-preserving operation that lets us transform the value wrapped by the structure:

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

Maybe has a Functor instance:

maybeFunctor :: Maybe Int -> Maybe String
maybeFunctor mInt = fmap show mInt

[] also has a Functor instance:

listFunctor :: [Int] -> [String]
listFunctor xs = fmap show xs

Even the function arrow has a Functor instance, when the type on the left-hand side of the arrow is fixed:

functionFunctor :: (a -> Int) -> (a -> String)
functionFunctor f = fmap show f -- a.k.a. (show . f)

A loosey-goosey intuition is that fmap lets us map over the output of a computation.

Contravariant

There is another “mapping” typeclass out there - Contravariant - that we likely don’t bump into as often as Functor:

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

On first blush, if we look at Contravariant with the more familiar Functor as our frame of reference, contramap’s type signature can be very puzzling. How can we apply a function with type (b -> a) to the guts of an f a structure?

A challenge we might face here is that considering we’re likely so used to common structures that have Functor instances, we may first try substituting in those same structures for f, e.g. Maybe, [], and so on. GHC will quickly tell us that there are no Contravariant instances for these structures, so at this point, we’ll need to look at Contravariant from a new angle.

Let’s look at a concrete example:

import Control.Exception.Safe (SomeException(SomeException), catch)
import Control.Monad (void)
import Data.Aeson (encode)
import Data.Text (Text)
import Network.HTTP.Client
  ( RequestBody(RequestBodyLBS), Manager, httpNoBody, parseUrlThrow, requestBody
  )

newtype Processor a = Processor
  { runProcessor :: a -> IO ()
  }

type LogMessage = Text

batchLogProcessor :: Manager -> Processor [LogMessage]
batchLogProcessor manager =
  Processor
    { runProcessor = \logs -> do
        let requestBody = RequestBodyLBS $ encode logs
        req <- parseUrlThrow "POST http://my-log-aggregation-service.org/post"
        void (flip httpNoBody manager req { requestBody })
          `catch` \(SomeException ex) -> do
            -- ... do something with the exception here ...
    }

The Processor type wraps a function that takes in as input some value, and then does something with that value in IO. We built a batchLogProcessor that lets us send a batch of logs up to some log aggregation service.

We can also define a convenient (albeit less performant) Processor that just sends up one log message at a time, rather than sending a batch. We’ll implement this one in terms of batchLogProcessor:

singletonLogProcessor :: Manager -> Processor LogMessage
singletonLogProcessor manager =
  Processor
    { runProcessor = \logMsg -> do
        runProcessor (batchLogProcessor manager) [logMsg]
    }

This type of operation - mapping over the input - is really the essence of Contravariant. We can define a Contravariant instance for Processor:

instance Contravariant Processor where
  contramap :: (b -> a) -> Processor a -> Processor b
  contramap f inputProcessor =
    Processor
      { runProcessor = \x -> do
          runProcessor inputProcessor $ f x
      }

With a Contravariant instance, we can now define singletonLogProcessor using contramap:

singletonLogProcessor :: Manager -> Processor LogMessage
singletonLogProcessor manager =
  contramap (\logMsg -> [logMsg]) $ batchLogProcessor manager

We’re also free to contramap to our heart’s content on all sorts of Processors, not just ones specific to log aggregation.