I want to show you a technique for building a new type theory quickly. I use it to test and refine type theoretic ideas, without spending too much time.

We’re going to build a small type theory by Induction-Recursion.

Consider the following Idris module:

module BTT1 %default total mutual data U : Type where UNat : U UPi : (a: U) -> (b: Elem a -> U) -> U Elem : U -> Type Elem UNat = Nat Elem (UPi a b) = (x: Elem a) -> Elem (b x)

This small module defines an inductive type U and a recursive function Elem mutually. Hence the name of the technique, induction-recursion.

The type U represents all the types in the type theory we’re building. It contains a UNat constructor for the type of natural numbers, and a UPi constructor for dependent function types.

The function Elem then interprets the types in U as Idris types. It interprets our natural number type UNat as the Idris natural numbers type Nat , and it interprets our dependent function type UPi a b as the Idris dependent function type (x: Elem a) -> Elem (b x) . Note the use in this interpretation.

U and Elem must be defined mutually because the dependent function type UPi a b is defined with respect to the interpretation function Elem , because a : U and b : Elem a -> U . Induction-recursion is the special sauce that gives us access to the value of a recursive function on an inductive type that we are still defining!

With these definitions, we can represent types like (x: Nat) -> Nat in our small type theory, as UPi UNat (\x => UNat) . We can also represent more complicated types, like functions that take a variable number of arguments, for example:

n_ary : Nat -> U n_ary Z = UNat n_ary (S n) = UPi UNat (\x => n_ary n) variadic : U variadic = UPi UNat n_ary

This type universe is small, but interesting enough. The point of this universe is not to have everything, but to leave lots of room for experimentation. So let’s run a few experiments.

Experiment 1: Adding A Simple Type.

The first experiment we’ll try is to add a new type. Let’s add a type for Booleans. To add a new type, the only thing you need to do is to give a new constructor in U , and to add a rule for it in Elem . You should end up with something similar:

mutual data U : Type where UBool : U UNat : U UPi : (a: U) -> (b: Elem a -> U) -> U Elem : U -> Type Elem UBool = Bool Elem UNat = Nat Elem (UPi a b) = (x: Elem a) -> Elem (b x)

Now you have Booleans in the small type theory! You can follow the same process to add a ton of simple types, such as Void or String .

Experiment 2: Adding A Dependent Type.

Addinga a dependent type is similar to adding a simple type, but now the constructor for the new type will involve Elem , the way UPi already does.

Let’s add Σ-types (dependent pairs) to accompany our Π-types. The type of the second member of the pair will depend on the value of the first member. You should end up with a definition like the following:

mutual data U : Type where UBool : U UNat : U UPi : (a: U) -> (b: Elem a -> U) -> U USigma : (a: U) -> (b: Elem a -> U) -> U Elem : U -> Type Elem UBool = Bool Elem UNat = Nat Elem (UPi a b) = (x: Elem a) -> Elem (b x) Elem (USigma a b) = (x: Elem a ** Elem (b x))

Notice that USigma is very similar to UPi . The interpretation for USigma uses the dependent pair type in Idris, which is denoted (x: A ** B x) .

Experiment 3: Changing The Universe Signature.

Now for a more substantial experiment. We can experiment with new signatures for the type theory itself. What this means is we are free to associate more information with each type.

In this experiment, we’ll build a pointed type theory, i.e. a type theory where each type has a known member, which we call the point of the type. We begin by laying out the signature of all the operations on the universe, inside a mutual block.

mutual data U : Type where ... Elem : U -> Type ... point : (a: U) -> Elem a ...

Now let’s fill these in for UNat and UPi .

mutual data U : Type where UNat : U UPi : (a: U) -> (b: Elem a -> U) -> U Elem : U -> Type Elem UNat = Nat Elem (UPi a b) = (x: Elem a) -> Elem (b x) point : (a: U) -> Elem a point UNat = ?point_rhs_1 point (UPi a b) = ?point_rhs_2

What should the right hand side for point be, when it comes to UNat and UPi ? Well they could be anything we want, but let’s go for some obvious choices:

For UNat , the point should be zero.

, the should be zero. For UPi a b , the point should be the function that takes each x : Elem a to the point of b x .

So we end up with:

point : (a: U) -> Elem a point UNat = Z point (UPi a b) = x => point (b x)

Now we added here an unrestricted dependent function type with UPi , but we should consider adding a function type that always maps points in the domain to points in the codomain. This is easy enough. Here’s how I added a pointed dependent function type UPointedPi :

mutual data U : Type where UNat : U UPi : (a: U) -> (b: Elem a -> U) -> U UPointedPi : (a: U) -> (b: Elem a -> U) -> U Elem : U -> Type Elem UNat = Nat Elem (UPi a b) = (x: Elem a) -> Elem (b x) Elem (UPointedPi a b) = (f: ((x: Elem a) -> Elem (b x)) ** f (point a) = point (b (point a))) point : (a: U) -> Elem a point UNat = Z point (UPi a b) = x => point (b x) point (UPointedPi a b) = ((x => point (b x)) ** Refl)

Note that the rules for UPointedPi rely on Idris’s built-in equality type. This is fine for experimentation, but the built-in equality may leave something to be desired. The next experiment will build in a customized notion of equality.

Exercise 1. Extend the initial example (the one with just U , Elem , UNat , and UPi ) to equip each type with a binary operation on it. Then try to add a type of dependent functions that preserve this binary operations. Why doesn’t that work? Read the next section to get some ideas for how to fix this. (Hint: you may need a more appropriate definition of type family.)

Experiment 4: Changing The Notion of Equality.

The final experiment today will be focused on changing the notion of equality. To make things easier for ourselves, we’re going to copy OTT (Observational Type Theory) by defining an equality relation between types, and a heterogeneous equality between values of different types. [A homogeneous equality would require dealing with transport much sooner.]

mutual data U : Type where UNat : U UPi : (a: U) -> (b: Elem a -> U) -> U UEq : U -> U -> Type UEq = ?ueq_rhs Elem : U -> Type Elem UNat = Nat Elem (UPi a b) = (x: Elem a) -> Elem (b x) ElemEq : (a,b: U) -> Elem a -> Elem b -> Type ElemEq = ?elemeq_rhs

So, the first thing to notice is that we’re adding two operations at the universe level: UEq which represents equality between types, and ElemEq which represents equality between values.

For type equality UEq it makes sense to equate UNat with UNat and UPi a1 b1 with UPi a2 b2 whenever they both arguments match. To phrase the comparisons between b1 and b2 in a way that typechecks, we need to use ElemEq .

UEq : U -> U -> Type UEq UNat UNat = () UEq (UPi a1 b1) (UPi a2 b2) = ( UEq a1 a2 , (x1: Elem a1) -> (x2: Elem a2) -> ElemEq a1 a2 x1 x2 -> UEq (b1 x1) (b2 x2)) UEq _ _ = Void

[To appease the Idris type checker down the line, it might actually be better to write out all the cases in a non-overlapping way, such as UEq UNat (UPi _ _) = Void and UEq (UPi _ _) UNat = Void .]

As for value equality, we can check that two naturals are the same using Idris’s built-in equality, and we can check that two functions are the same by checking that equal input values are mapped to equal output values.

ElemEq : (a,b: U) -> Elem a -> Elem b -> Type ElemEq UNat UNat n1 n2 = (n1 = n2) ElemEq (UPi a1 b1) (UPi a2 b2) f1 f2 = (x1: Elem a1) -> (x2: Elem a2) -> ElemEq a1 a2 x1 x2 -> ElemEq (b1 x1) (b2 x2) (f1 x1) (f2 x2) ElemEq _ _ _ _ = Void

[Again, it may be better to write the last case in a non-overlapping way, so as ElemEq UNat (UPi _ _) _ _ = Void and ElemEq (UPi _ _) UNat _ _ = Void .]

[Unlike the original OTT paper, ElemEq a b x y does not imply UEq a b . We could recover that property by requiring UEq a1 a2 in the definition of ElemEq for UPi values. It doesn’t make a difference at this point.]

So we have a definition of equality at both the type and value level. We could say “we’re done” but there’s something odd about our current definition of UPi . Namely, we allow the b to distinguish between equal at the type level. To fix this, if we want to, we need to change the definition of UPi .

UPi : (a: U) -> (b: Elem a -> U) -> ((x1, x2: Elem a) -> ElemEq a a x1 x2 -> UEq (b x1) (b x2) ) -> U

The third argument restricts b to necessarily respect the universe equality. It’s quite verbose, so my suggestion is to factor out the notion of a well-behaved type family, and the notion of type family equality, into Fam and FamEq definitions as follows:

mutual data U : Type where UNat : U UPi : (a: U) -> (b: Fam a) -> U FamEq : (a1,a2: U) -> (b1: Elem a -> U) -> (b2: Elem b -> U) -> U FamEq a1 a2 b1 b2 = (x1: Elem a1) -> (x2: Elem a2) -> ElemEq a1 a2 x1 x2 -> UEq (b1 x1) (b2 x2) Fam : U -> Type Fam a = (b: (Elem a -> U) ** FamEq a a b b) UEq : U -> U -> Type UEq UNat UNat = () UEq (UPi a1 (b1 ** _)) (UPi a2 (b2 ** _)) = ...

Likewise, elements of UPi don’t necessarily respect the equality of the domain type. A similar refactoring will fix this (see Exercise 2).

Exercise 2. Define a notion of dependent function equality FunEq , where functions don’t necessarily have the same type (take the types a1 , a2 and families b1 , b2 as arguments). Then define well-behaved functions in terms FunEq , replacing the current definition of Elem (UPi a b) so that it respect equality on the domain type.

Exercise 3 (hard). Prove that UEq and ElemEq are reflexive, symmetric, and transitive. Prove also that you can transport values across a type equality, and that the transport operation preserves value:

transport : (a1, a2: U) -> (h: UEq a1 a2) -> (x: Elem a1) -> Elem a2 transportEq : (a1, a2: U) -> (h: UEq a1 a2) -> (x: Elem a1) -> ElemEq a1 a2 x (transport a1 a2 h x)

Conclusion

I hope I’ve shown you a useful trick for building an experimental type theory. This technique is good for a quick prototype, so you can refine initial ideas to make them workable.

Where this technique falters is that it relies a bit heavily on the host type theory, and you will find more friction when you try to add features to your type theory that are not present in the host language. Definitions can spiral out of control under the burden of proof, because what would be automatic when working internally in your new type theory must be proven manually in the host type theory.

Join me next time, possibly in the far future, where I talk more about building type theories.