Peeling zeroes in Idris

October 28, 20219 min read

🎶 We’re no strangers to lists, you know the rules and so do I 🎶

Anyway, here’s one:

theBeatles : List String
theBeatles = ["John", "Paul", "George", "Ringo"]

If we want to get an element from a list at a specific index, we can reach for a function like index:

index : Int -> List a -> Maybe a

Which we would use like this:

> index 0 theBeatles
Just "John"

> index 6 theBeatles

The reason this function returns a Maybe is that we could pass any number to it. It seems thus inescapable that we need to handle the case when the given number is “out of bounds”. It seems impossible to conjure a function which at the same time is safe and behaves like this:

index : Int -> List a -> a

This seems peculiar because unless something quite monstrous happens we know that there will be no further changes to the Beatles lineup. So the problem is that we know this but the type system doesn’t, and it has to treat that list as it would treat any other list.

Is there no way out?

Enter Idris

In Idris there is another data structure that can describe lists called Vect:

theBeatles : Vect 4 String
theBeatles = ["John", "Paul", "George", "Ringo"]

What’s going on here?

The type also contains information about the length of the list. Let’s see what happens if we forget one of the Fab Four:

theBeatles : Vect 4 String
theBeatles = ["John", "Paul", "George"]

Idris complains:

When unifying Vect 0 String and Vect 1 String.
Mismatch between: 0 and 1.

 2 |
 3 | import Data.Vect as Vect
 4 |
 5 | theBeatles : Vect 4 String
 6 | theBeatles = ["John", "Paul", "George"]

Don’t worry too much about the specifics of the error message, just look at that little caret that Idris puts at the end. It’s almost as the compiler is telling us “Hey, something’s missing there”.

With this data structure we can write something like this:

> Vect.index 0 theBeatles

Note that we get the raw value, no Maybe nonsense here. What about when we pass a number that’s out of bounds?

> Vect.index 6 theBeatles
Error: Can't find an implementation for
       IsJust (integerToFin 6 4).

 1 | Vect.index 6 theBeatles

Woah, now the compiler understands. Cool. But what’s the type signature of this index function?

> :t Vect.index
Vect.index : Fin len -> Vect len elem -> elem

Uh, what that’s Fin type? It describes a natural number strictly less than some bound. The name comes from “finite sets”, i.e. sets that have a finite number of elements. Its definition is:

data Fin : Nat -> Type where
  FZ : Fin (S k)
  FS : Fin k -> Fin (S k)


We might need to take a little step back to unpack it.

Natura, artis magistra

Natural numbers are integers greater or equal than zero. A neat trick to represent them is to create a recursive type:

data Nat
  = Z
  | S Nat

Where Z represents zero, while S Nat represents the successor of a certain natural number.

Using this type we can construct the following values:

Z           == 0
S Z         == 1
S (S Z)     == 2
S (S (S Z)) == 3

You can see that Idris does a little bit of magic under the hood so that we can write 1 instead of S Z. Neat.

A somewhat interesting fact is that we can also get the previous natural number by removing a layer of S:

prev : Nat -> Maybe Nat
prev Z = Nothing
prev (S x) = Just x
> prev 0

> prev 1
Just 0

> prev 2
Just 1

Now that we know how to express natural numbers in Idris, we can take a look at our finite subsets of natural numbers.

The Meat and Potatoes

Let’s look at that definition again:

data Fin : Nat -> Type where
  FZ : Fin (S k)
  FS : Fin k -> Fin (S k)

This looks a tad different from the previous data Nat = Z | S Nat definition, but it’s quite close:

  • We are defining a function called Fin that takes a natural number and returns a type.
  • One possible constructor is FZ, which stands for “finite zero”.
  • The other constructor is FS, which takes an existing Fin and returns its successor.

From this explanation it’s not clear how we are restricting these natural numbers, so let’s play around with some finite numbers. Since a literal 0 can be an Int, a Nat, and many other things, we have to use the special the function to tell Idris which specific type we want:

> the (Fin 1) 0

So everything is good when we ask for a 0 in Fin 1, the set of natural numbers strictly less than 1. How about this?

> the (Fin 0) 0

It blows up!

Error: Can't find an implementation
for IsJust (integerToFin 0 0).

 1 | the (Fin 0) 0

Why is this happening? By definition the minimum value we can create is FZ. The type of this value is Fin (S k), and by definition S k must be greater than zero. So this means that the smallest set we can create is Fin 1, which is the set that only contains zero.

Let’s look at Fin 2, the set that contains 0 and 1.

> the (Fin 2) 0

> the (Fin 2) 1

As expected, the zeroes in Fin 1 and Fin 2 are different:

> the (Fin 1) 0 == the (Fin 2) 0
Error: When unifying Fin 1 and Fin 2.
Mismatch between: 0 and 1.

 1 | the (Fin 1) 0 == the (Fin 2) 0

Ok, time for a little surprise. Look at these values again:

> the (Fin 2) 0

> the (Fin 2) 1

Those FZ values are not the same! The first one is of type Fin 2, while the second one is of type Fin 1. This makes sense when we look at the type of the FS function:

FS : Fin k -> Fin (S k)

Or making it more concrete:

FS : Fin 1 -> Fin 2
  • we get a 0 of type Fin 1
  • we apply FS
  • we get a 1 of type Fin 2

How would we get a 2 of type Fin 2? We would need to apply the FS function twice, going from Fin 0 to Fin 1 to Fin 2, right?

The problem is, we simply cannot do that.

The smallest valid finite set is Fin 1, which means we just cannot apply that function twice to create that value. You can only apply it once, giving us 1, which is the largest element in Fin 2.

If we look at Fin 3, things start making more sense:

> the (Fin 3) 0

> the (Fin 3) 1

> the (Fin 3) 2

> the (Fin 3) 3
Error: Can't find an implementation
for IsJust (integerToFin 3 3).

 1 | the (Fin 3) 3

It’s like we’re starting with this beefy zero, and we need to peel a layer to be able to create the next number. Then we keep peeling and peeling, up until we just can’t peel anything anymore. Fin 1 is the end of all hopes, the last laugh, the final curtain.

And Now for Something Completely Different

If you’ve followed so far, you’re in for a treat 🍭

We’ve talked a lot about Vect and Fin, but you don’t need any of that to reap the benefits we’ve talked about! Let’s go back to lists:

theBeatles : List String
theBeatles = ["John", "Paul", "George", "Ringo"]

Look at this:

> List.index 1 theBeatles

What??? How is it possible it just returns a value, no Maybe in sight?

> List.index 5 theBeatles
Error: Can't find an implementation for InBounds 5
  ["John", "Paul", "George", "Ringo"].

 1 | List.index 5 theBeatles

Just how. Let’s dig in:

> :t List.index
List.index :
  (n : Nat) ->
  (xs : List a) ->
  {auto 0 _ : InBounds n xs} ->

If we look at the third argument we notice some curly braces around it. I won’t go too much in detail, but that’s an implicit argument that doesn’t need to be passed in when calling the function.

The interesting bit for us is that InBounds type:

data InBounds : Nat -> List a -> Type where
  InFirst : InBounds 0 (x :: xs)
  InLater : InBounds k xs -> InBounds (S k) (x :: xs)

That looks complicated. But how about we compare it to this?

data Fin : Nat -> Type where
  FZ : Fin (S k)
  FS : Fin k -> Fin (S k)
  • There is a base case. We use x :: xs to signal that the list must not be empty, so that we can never get an InFirst value of type InBounds 0 [].
  • There is a function that we can call recursively to peel a layer. In this case, not only do we go from k to S k, but we also specify that the list gets an extra element in front.

If you squint a bit, you’ll see it’s the exact type trick we’ve seen before. If we tried to call List.index 1 ["John"] we’d have to find a way to call the InLater function to peel off a layer. Or more concretely:

InLater : InBounds k xs -> InBounds (S k) (x :: xs)
-- becomes
InLater : InBounds 0 [] -> InBounds 1 ["John"]

The problem is constructing that InBounds 0 []. By definition InFirst has to be InBounds 0 (x :: xs), so we simply cannot create such value. Thus the type system cannot find a valid implementation of InBounds 1 ["John"], which is exactly the error message Idris prints out. QED

I hope you enjoyed this adventure in the land of zeroes and dependent types.

Fin 1.

Ju Liu

Personal blog by Ju Liu.

I try to write code that doesn't suck. I rarely succeed.