Fantas, Eel, and Specification 7: Contravariant

Well, well, well. We’re a fair few weeks into this - I hope this is all still making sense! In the last article, we talked about functors, and how they’re really just containers to provide “language extensions” (or contexts). Well, today, we’re going to talk about another kind of functor that looks… ooky spooky:

-- Functor
map :: f a ~> (a -> b) -> f b

-- Contravariant
contramap :: f a ~> (b -> a) -> f b

It’s no typo: the arrow is the wrong way round. This blew my mind. It looks like contramap can somehow magically work out how to undo a function. Imagine the possibilities:

// f :: String -> Int
const f = x => x.length

// ['Hello', 'world']
;['Hello', 'world'].map(f).contramap(f)

Impossible, I hear you say? Well…

Yes, you’re right this time. Array isn’t a Contravariant functor - just a normal Functor (which we can more specifically call a covariant functor). In fact, there’s no magic here at all if we look at the examples of functors that are potential Contravariant instances. Try this one for size:

// type Predicate a = a -> Bool
// The `a` is the *INPUT* to the function!
const Predicate = daggy.tagged('f')

// Make a Predicate that runs `f` to get
// from `b` to `a`, then uses the original
// Predicate function!
// contramap :: Predicate a ~> (b -> a)
//                          -> Predicate b
Predicate.prototype.contramap =
  function (f) {
    return Predicate(
      x => this.f(f(x))
    )
  }

// isEven :: Predicate Int
const isEven = Predicate(x => x % 2 === 0)

// Take a string, run .length, then isEven.
// lengthIsEven :: Predicate String
const lengthIsEven =
  isEven.contramap(x => x.length)

The Predicate a is saying that, if I can get from my type a to Bool, and you can get from your type b to a, I can get from b to Bool via a, and hence give you a Predicate b! Sorry if that bit takes a couple of read-throughs; in short, lengthIsEven converts a String to an Int, then to a Bool. We don’t care that there’s an Int somewhere in the pipeline - we just care about what the input value has to be.

All smoke and mirrors, right? There’s no magical undo here; it’s just that we’re adding our actions to the beginning of a mapping. Trust me: you’re not as heartbroken as I was.

Still, if we can see past this betrayal, we’ll of course see that there are some cool things going on here. First of all, our laws are basically just the same as Functor, but a little bit upside down:

// Identity
U.contramap(x => x) === U

// Composition
U.contramap(f).contramap(g)
  === U.contramap(x => f(g(x)))

Identity is the same because “doing nothing” is still “doing nothing” if you do it backwards. Probably not a sentence that’ll win me awards. Composition is pretty much the same, but the functions are composed the other way round!

A lot - probably the overwhelming majority - of Contravariant examples in the wild will be mappings to specific types. Imagine a ToString type:

// type ToString a :: a -> String
const ToString = daggy.tagged('f')

// Add a pre-processor to the pipeline.
ToString.prototype.contramap =
  function (f) {
    return ToString(
      x => this.f(f(x))
    )
  }

// Convert an int to a string.
// intToString :: ToString Int
const intToString =
  ToString(x => 'int(' + x + ')')
  .contramap(x => x | 0) // Optional

// Convert an array of strings to a string.
// stringArrayToString :: ToString [String]
const stringArrayToString =
  ToString(x => '[ ' + x + ' ]')
  .contramap(x => x.join(', '))

// Given a ToString instance for a type,
// convert an array of a type to a string.
// arrayToString :: ToString a
//               -> ToString [a]
const arrayToString = t =>
  stringArrayToString
  .contramap(x => x.map(t.f))

// Convert an integer array to a string.
// intsToString :: ToString [Int]
const intsToString =
  arrayToString(intToString)

// Aaand they compose! 2D int array:
// matrixToString :: ToString [[Int]]
const matrixToString =
  arrayToString(intsToString)

// "[ [ int(1), int(2), int(3) ] ]"
matrixToString.f([[1, 3, 4]])

It’s pretty clear to see how this approach could be used to develop a serializer: you could output JSON, XML, or even your own new format! It’s also a great example of the beauty of composition: with functions like arrayToString, we’re using smaller ToString instances to make instances for other, more complex types!

Another good example that’s worth a look is the Equivalence type:

// type Equivalence a = a -> a -> Bool
// `a` is the type of *BOTH INPUTS*!
const Equivalence = daggy.tagged('f')

// Add a pre-processor for the variables.
Equivalence.prototype.contramap =
  function (g) {
    return Equivalence(
      (x, y) => this.f(g(x), g(y))
    )
  }

// Do a case-insensitive equivalence check.
// searchCheck :: Equivalence String
const searchCheck =

  // Basic equivalence
  Equivalence((x, y) => x === y)

  // Remove symbols
  .contramap(x => x.replace(/\W+/, ''))

  // Lowercase alpha
  .contramap(x => x.toLowerCase())

// And some tests...
searchCheck.f('Hello', 'HEllO!') // true
searchCheck.f('world', 'werld')  // false

So, we’re saying we can compare anything that works with ===, and we can therefore compare values of any type as long as they can be converted to something that works with ===. For searchCheck, this is really neat - we can supply steps for making a value comparable, for transforming single values, and the Contravariant instance will compare the inputs after being transformed accordingly. Hooray!

If you fancy an exercise, why not play around with an Equivalence using our Setoid comparison - perhaps a starter function of (x, y) => x.equals(y)? This should give a lot more control when comparing complex types.


Well, that’s about it! There’s not much to Contravariant types, and they’re relatively rare. However, they’re a really good way of making your code more expressive (or self-documenting, or whatever we call it at the moment):

filter :: Predicate   a -> [a] ->  [a]
group  :: Equivalence a -> [a] -> [[a]]
sort   :: Comparison  a -> [a] ->  [a]
unique :: Equivalence a -> [a] ->  [a]

I’ll leave it to you to write the Comparison type and its contramap - it’ll look quite a lot like Equivalence - but you see that these type signatures make it really clear what the functions are probably going to do.

If all else fails, just remember:

  • When f is a (covariant) Functor, f a says, “If you can give me an (a -> b), I can give you a Functor b”.

  • When f is a Contravariant functor, f a says, “If you can give me a (b -> a), I can give you a Contravariant b”.

It’s the same - it’s just backwards. There’s sadly no way we could write contramap for an array, but do think about why we also couldn’t write a map for Predicate - some things just aren’t meant to be! Sigh.

One last thing before you go: many of these types are monoids. See? Everything’s connected:

// It's like a function to our `All` monoid!
Predicate.prototype.empty = () =>
  Predicate(_ => true)

Predicate.prototype.concat =
  function (that) {
    return Predicate(x => this.f(x)
                       && that.f(x))
  }

// The possibilities, they are endless
Equivalence.prototype.empty = () =>
  Equivalence((x, y) => true)

Equivalence.prototype.concat =
  function (that) {
    return Equivalence(
      (x, y) => this.f(x, y)
             && that.f(x, y))
  }

How about that? We can combine various Predicate and Equivalence instances of the same type to make new instances!

Imagine a search tool with options for search criteria and strictness, with each one represented as an Equivalence structure. When the user makes a selection, we just combine the selected structures, and we have our purpose-built search utility!

Something to explore! Next time, we’ll talk about Apply (and probably Applicative) - my second favourite typeclass (after Comonad - we’ll get to that one in a few more weeks!) I hope you’re all well, and hopefully learning a thing or two along the way. See you in a week!

Take care ♥