Recursive types

Can we express nat in terms of sums and products?

nat = [z: unit;  s: ?]

well, the s alternative carries a natural, so:

nat = [z: unit;  s: nat]

This is a recursive type equation. In general, we have data structures with a regular structure that may grow to arbitrary size. Naturals, lists, trees, abstract syntax, derivations, etc. However, recursive types are hardly limited to standard conceptions of data structures. Once function or lazy types are involved, the range of expressible structure becomes far wider than what is possible with eager sums and products alone.

Type Isomorphisms

Directly equating nat and [z: unit; s: nat] can complicate both the metatheory and implementation of recursive types. So, instead of talking about equality, we will talk about isomorphism.

We can say two types are isomorphic if we have two functions

f: T1 -> T2
g: T2 -> T1

that are mutually inverses (that is, both for all x:T2, f(g x) = x and for all y:T1, g (f y) = y. That is, f o g is the identity on T2 and g o f is the identity function on T1.)

We introduce a new type operator mu to define recursive types that will satisfy equations of the type above up to isomorphism. For nat, we have:

nat = mu(t.[z: unit;  s: t])

which means, “nat is the (infinite) type satisfying the isomorphism t ~= [z: unit; s: t].” Generally,

mu(t.T) ~= [mu(t.T)/t]T.

That is, the recursive type mu(t.T) is the solution the above isomorphism.

Examples

nat is one of the simplest and most basic recursive types. Here are a few more examples.

List of natural numbers (with nil and cons alternatives):

nat_list = mu(t.[n: unit; c: (nat * nat_list)])

Binary trees (with leaf and branch alternatives):

tree = mu(t.[l: unit; b: (nat * tree * tree)])

Details

“Free” isomorphism

To witness the isomorphism induced by mu, we will introduce two corresponding constructs: fold(e) and unfold(e), which convert a term of recursive type back and forth between these two isomorphic types.

T ::= t | mu(t.T)
e ::= fold[t.T](e) | unfold(e)

Statics (rules 16.2a-b)

                  G |- e : [mu(t.T)/t]T
               ------------------------------- 
               G |- fold[t.T](e) : mu(t.T)

                      G |- e : mu(t.T)
               -------------------------------- 
               G | unfold(e) : [mu(t.T)/t]T

Dynamics (rules 16.3a-d)

                            e val
                      ------------------ 
                      fold[t.T](e) val

                           e -> e'
              --------------------------------- 
              fold[t.T](e) -> fold[t.T](e')

                           e -> e'
                   ----------------------- 
                   unfold(e) -> unfold(e')

                      fold[t.T](e) val
                 --------------------------- 
                 unfold(fold[t.T](e)) -> e

Key is last rule: elimination is inverse of introduction.

Type safety – standard.

Examples revisted:

Nats:

z = fold(z.<>)
s(e) = fold(s.e)
ifz(e; e0; x.e1) => case unfold(e) {z._ -> e0 | s.x -> e1}

where underscore means “ignore”.

List of nats:

nil = fold(n.<>)
cons(e1;e2) = fold(c.<e1,e2>)

isnil = \(l:nat_list) case unfold(e) {n._ -> true | c._ -> false}
hd = \(l:nat_list) case unfold(l) {n._ -> z | c.x -> x.1}
tl = \(l:nat_list) case unfold(l) {n._ -> l | c.x -> x.2}

Note: we arbitrarily choose values for the error cases of hd and tl because we do not have exceptions.

The “hungry” function:

hungry = fix[H](f.fold(\(x:nat) f))
H = ?
unfold(hungry)(3) = ?
unfold(unfold(hungry)(3))(9) = ?

Answers:

H = mu(t.nat -> t)
unfold(hungry)(3) = hungry
unfold(unfold(hungry)(3))(9) = hungry

Recursive values from recursive types.

Aside: equi-recursive types

For brevity, we will now treat isomorphism implicitly, leaving out the explicit folding and unfolding. Here is a well-typed fixed-point combinator:

fix_T : (T -> T) -> T
fix_T = \(f : T -> T) (\(x : mu(t.t -> T)) f (x(x))) (\(x : mu(t.t -> T)) f (x(x)))

So, recursive types are enough to allow general recursion. Key point is that we can view the variable x in the self-application (x x) at two different (but isomorphic) types: mu(t.t -> T) -> T and mu(t.t -> T).

However, there’s more: we can type the “untyped” lambda calculus with recursive types, from which the typability of the Y-combinator comes for free.

As Bob showed early on, the lambda calculus is built on this recursive type equation:

D ~= D -> D

As we’ve seen, D can be solved with a recursive type:

D = mu t.t -> t.

So, the lambda calculus has but one type: it is “uni-typed” rather than “untyped”. We can translate terms from our previous syntax to a typed syntax as follows:

       x# = x
(\(x) e)# = fold(\(x:D) e#
 (e1 e2)# = unfold(e1#) (e2#)

As with HW1’s SKI tasks, you can check that the translation is sound and complete. Put another way, that beta-reduction commutes with the translation. However, you’ll notice that the typed version is rather heavy-weight because of the need to deal with all of the folding and unfolding. Of particular interest is the case of applicatoin wherein the unfold implicitly checks that e1 is in fact a function.

Which leads us to “dynamic typing”…

Edit