A little extra safety with RankNTypes

Posted on January 1, 2021

In solving AoC 2020 day 20, I found a neat use for RankNTypes that I anticipate using again when I can.

To see what RankNTypes gives us, let’s first look at an ordinary higher-order function.

map :: (a -> b) -> [a] -> [b]

This function is polymorphic, meaning that its type has some type variables, namely a and b. When we call map, we pass not only two value arguments of type a -> b and [a], but also two type arguments a and b. Usually, this second passing is hidden from us, but there is an extension to make it explicit.

Crucially, this means that when we define map, the function parameter is not a polymorphic function! It’s an ordinary function, with a somewhat mysterious type. All we know about it in that context is that the input type of the function happens to be the same as the element type of the list.

Back to the AoC problem. In part 1, we don’t care about the interior of the tile, but rather only about its edges. And for maximum flexibility, we can choose not to commit to a particular edge type. This gives a definition like

data Tile a = Tile { left :: a, top :: a, right :: a, bottom :: a }

It’s nothing more than a 4-tuple with a name.

Usually, the variable a will be instantiated with a type Edge that is the concrete representation of an edge of a tile, but by using a type variable, we can separate generic tile transformations (such as rotations and flips) from more specialized ones that depend on the particulars of the edges.

A crucial part of the solution is an algorithm that given a pair of tiles and a pair of sides finds a transformation (a rotation + a flip) for the second tile that brings the edges on those sides of the tiles into alignment. I chose to represent edges clockwise, like this:

a b c d e
p       f
o       g
n       h
m l k j i

There are benefits to this representation that I won’t get into, but suffice to say that two edges e1 and e2 are aligned in this representation if e1 is equal to the reverse of e2.

The basic idea of the alignment algorithm is to check if the edges on the desired sides of the tiles are aligned – that’s the base case, and the output transformation then is identity – else, rotate the second tile and repeat. We could error out (with Maybe) in case we rotate 4 times and we still haven’t found an alignment, but instead we’ll just say that the alignment algorithm has a precondition that the alignment has to exist. It merely finds the alignment.

Now here’s the punchline, and why RankNTypes gives us extra safety: a side of a tile is… a polymorphic function!

You might be thinking I’m contradicting myself, because I defined the edges of a Tile a to be of type a and there are no functions in sight here. But a side is what I’m calling a way of selecting an edge from a tile.

newtype Side = Side (forall a. Tile a -> a)

It’s the RankNTypes extension that enables us to write forall a here, and the meaning is that the function contained in this newtype is polymorphic. And there are exactly four (legitimate) values of the type Side: each is built from one of the record selectors left, top, right, and bottom.

We’re at last ready to write the type of the alignment algorithm.

  :: (Eq a, Reversible a)
  => (Tile a, Side) -> (Tile a, Side)
  -> Transf a

Now you might be asking yourself: why bother? We could have just written:

  :: (Eq a, Reversible a)
  => (Tile a, Tile a -> a) -> (Tile a, Tile a -> a)
  -> Transf a

And that’s completely true. We could have written this. But this way of expressing align falls victim to a possibility of misuse. Remember how when we called map at the beginning of this post, the call site chose the instantiations for the type variables (even though it happened implicitly)? Well suppose we call align with a concrete instantiation for a, e.g. Edge. Then we need to supply functions of type Tile Edge -> Edge. Because we know that these functions return Edges, we can give implementations that don’t actually return edges of the input tile! On the other hand, by using the Side type, we buy ourselves a little extra safety at a very low cost: we just need to wrap and unwrap the newtype Side to completely eliminate this potential coding error.

What’s more is that this technique is applicable even to functional languages without the plethora of language extensions that GHC supports. (Well, at least one other not fully dependently-typed functional language.) For example, OCaml also lets you pass polymorphic functions inside records.

type side = { side : 'a. 'a tile -> 'a }

To sum up, the major takeaway I had from this journey is that it can be hugely beneficial to avoid committing to representations for as long as possible. By deciding earlier to define a tile using a type variable, I’m enabled to define a precise and safe notion of Side to select edges. This is an application of one of the bigger principles of type-driven development: make illegal states unrepresentable. Going forward, I hope to find equally cheap ways to do just that.

P.S. This article does simplify a few points. In case you’re interested in the gory details, have a look at the code.