Type Systems #10: Universal Quantification and Rank-N Types


(This is part 10 in a series on type systems. You can find part 9 here .)

In post #5 I claimed that System F generics shouldn’t be a huge conceptual leap. Looking at a type like toString :: ∀A. A → String, you’ll probably immediately know what it does — the function takes anything and turns it into a string.

In post #8 we even looked at a very degenerate case, error :: ∀R. String → R, that can never return at all, which is probably less obvious to most.

But there is one thing all the generic functions we’ve looked at so far have in common… we always put the symbol — the universal quantifier — at the very front of the type.

Nobody says that we have to do that, it’s just that this kind of generic function is the only one we deal with in day-to-day programming, because the major programming languages only allow for this specific kind of generic function. But System F is actually a lot more powerful than that.

Since this is where we leave the comfort zone of “working man’s knowledge”, we will slow the pace to explore this new concept. From now on I will also drop explicit type annotations in function definitions, if they can be inferred from context, to reduce visual noise. (This is problematic in System F but we’ll talk about that later.)

Have a look at this function definition:

apply10 :: (∀A. AA) → Int
apply10 = λf. f 10

Here, the quantifier ∀A is not at the outermost layer of the type, but rather inside the function argument. We call this sort of type a Rank-N Type, where the N indicates how deeply nested the universal quantifier is, in this case 2.

This is markedly different from the rank-1 type weird :: ∀A. (A → A) → Int, because whoever calls weird gets to choose what A is — this is not the case for apply10: if you wish to call apply10, you have to provide a generic function.

(Hard) bonus question: apply10 isn’t a particularly useful function. Why?

Question 1:
Can you explain in words what apply10 does?

Solution: It always returns 10.

You must provide an argument of type ∀A. A → A, and as discussed in post #8, there is only one function that has this type: the identity function that does nothing but return its input.


Let me show you an actually useful example of a rank-2 type as well, assuming Haskell semantics for a moment:

withFile :: FilePath → (∀R. FileHandleR) → Void

This function takes a path to a file, and a continuation. withFile will do some file opening logic and then hand a file to another function.

As discussed in post #8, the continuation’s type ∀R. FileHandle → R might make you scratch your head, because such a function can never return, or in a Haskell context, only return an error.

I must clarify then that returning an error in Haskell is perfectly fine and will not blow up your program, as long as you don’t try to access it. That is to say, the Haskell snippet let x = error "oops!" in 42 will happily evaluate to 42 without crashing. let x = error "oops!" in x on the other hand will blow up.

So — why is genericising the return type like that useful?

Well — since your continuation must return an error, it can in particular not return anything else, like the file handle, and it cannot leak the file handle to the outside world in sneaky ways.

Haskell does have a way to reference “global-ish” state using IORefs, but if you want to for example call modifyIORef to sneakily put the file handle in a global state so you can use it later, Haskell would force you to return a value of type IO (), meaning the continuation would have a type of FileHandle → IO (), which is not ∀R. FileHandle → R, so it won’t typecheck.

Again, we’ve enforced a very desirable property at the type level. We can safely assume that the continuation has done whatever it needed to do with the file and the file is no longer needed, so withFile can close it after the continuation ran. In so many other programming languages, this would require careful runtime checks by the programmer… here we just get it for free by the type system, and the compiler can enforce it for us! [1]

It’s theorems for free all over again!

(This is part 10 in a series on type systems. You can find part 11 here .)


[1] Rust and Haskell with ghc 9+ also have different ways to enforce this sort of constraint at compile time, with the borrow checker and linear types respectively, but that’s a topic for another day.