Dependent type
Table of Contents
- Let
U
be a universe of types - Let
A
be a typeA: U
- Let
B
be a family of typeB : A → U
- it assigns each term
a
(a: A
) - a type
B(a)
- We say that the type
B(a)
varies witha
- a type is "inhabited" if a value of that type exists
- hence having a value is a proof that a type is inhabited
1. Dependently typed programming languages
- Rocq (1989)
- ATS (Applied Type System) (2006)
- Idris (2007)
- Agda (1999)
- Agda 2 (2007) is a new language
- Epigram (2004)
- the syntax looks cursed… it's 2D!
- F* (2011)
- from Microsoft Research and Inria
- Lean (2013)
- by Leonardo de Moura (!) at Microsoft Research
2. TODO Dependently typed pattern matching
3. More things to explore
- homotopy type theory (HoTT)
- refinement types
- type + predicate
4. Stuff from djeis on lisp discord (paraphrased or not)
https://discord.com/channels/297478281278652417/377481417334063105/1345545063119327272
- a type theory with dependent types is a proof system
- having a value is a kind of proof that whatever type that value has, does have elements, that the type is not empty.
- with dependent types, you can express types that are only nonempty if some corresponding statement in logic is provable
- And the value you construct to show that type is nonempty is a proof of the corresponding logical statement.
- about quantifiers:
forall
lets you express that the [later argument types and] return type of a function varies based on the value of its inputsexists
lets you express that- one of the fields of a record
- has a type that varies based on the
- value of another field.
- In dependent type theories, a type can be passed around like a value
and those A and B become real arguments.
- So you might see something like
map int string intToString foo
- So you might see something like
- type signature for a length-preserving map:
forall (A B : Type) (n : int), (A -> B) -> Vector A n -> Vector B n
n
doesn't have to be a compile-time constant- but the compiler must be able to statically recognize that
n
will match the length of the input vector at runtime - Which practically means it needs to be involved in constructing that vector, one way or another.
struct { size_t l; int[l] v; }
When you construct a value of that struct, the compiler will only let
you provide an l
and a v
that it can statically tell will have
that length relationship, even if it doesn't know the specific length
statically.
- you can prove an induction by writing a recursive function
4.1. BHK interpretation
- Brouwer–Heyting–Kolmogorov interpretation of intuitionistic logic
- https://en.m.wikipedia.org/wiki/Brouwer%E2%80%93Heyting%E2%80%93Kolmogorov_interpretationIt is the standard explanation of intuitionistic logic.
- "It is the standard explanation of intuitionistic logic."
- "not" P means that P leads to absurdity
- to avoid Gödel ;)
- To summarize and get it out of the way:
and
andor
are thepair
andsum
types, respectively.- Evidence that A and B are both nonempty?
- A pair of an A and a B.
- Evidence that A or B is nonempty?
- A value that is either holding an A or holding a B.
4.2. Generalization for ADTs to Inductive types
Where this gets interesting is how dependent types generalize ADTs into Inductive types.
4.2.1. Maybe type
So in a lot of FP langs you have polymorphic algebraic data types like
Maybe
:
data Maybe a = Some a | None
It's a type with
- a param,
- two constructors,
- one of them takes a value of that type param
In a dependent type theory you might see a definition like that, but it’d be syntactic sugar for the more explicit:
Inductive Maybe (a : Type) : Type := | Some : a -> Maybe a | None : Maybe a
Where we instead
- give type signatures for each constructor explicitly,
- which each need to end in returning the type we’re defining.
Actually in the end the type signatures of the constructors will also include the type parameter:
Some : forall (a : Type), a -> Maybe a
4.2.2. List type
Getting a little trickier, we could also translate a list type:
data List a = nil | cons a (List a)
desugars to
Inductive List (a : Type) : Type := | nil : List a | cons : a -> List a -> List a
Both of these are polymorphic types, or more formally they’re polymorphic type families.
- A group of types related by a common type former and common
polymorphic constructors.
4.2.3. Vector (Length-indexed list type)
The next step beyond a List type? A length-indexed list type. This I can’t express in ADT notation:
Inductive Vector (a : Type) : nat -> Type := | Vnil : Vector a 0 | Vcons : forall (n : nat), a -> Vector a n -> Vector a (1 + n)
Compare this with the definition of List and a few things stand out:
- the signature of the type former has gone from
Type
tonat -> Type
, cons
has an extra parameter for a length, and- the constructors no longer have exactly the same return type.
Trying to understand the syntax (this isn't valid, it's just to help me group things logically):
(Vector int): nat -> Type
Vector : Type -> nat -> Type
4.2.4. Type index
So, the first change: the type former now describes a family not just parameterized by the element type but also by the length.
However, by placing that nat
after the :
, it’s not scoped over
the rest of the constructors as well. (a
is)
This is called a type index, and what it means is each of our constructors can now involve multiple members of the type family at once and even determine which one they return.
Vnil
shows that: it specifically returns a value of the 0-length vector types (one for each possible element type).Vcons
gets even more interesting:- like
cons
it takes a value and a sublist (well, sub-vector). - But now it has to be polymorphic in the length of that sub-vector, hence the extra parameter.
- and it uses an expression to decide its return type:
- it produces a value of the vector type one longer than its sub-vector.
- like
So,
Vnil
always produces vectors in the 0 length types, andVcons
(essentially) increments the type index of its sub-vector at the same time as it collects an additional element.
Those two properties actually enforce the type index being the vector length, in a way that the compiler will keep track of for you.
Skipped a message about the type for a length-preserving map
4.2.5. Family of equal types
From these same building blocks we can make things like the family of
equality types
.
Values of which represent evidence that two expressions will evaluate to the same thing at runtime,
and type families that correspond to inductively defined logical props and relations. I’ll stop here for now tho. 😅
It has a rather nauseating definition at first glance, imo.
Inductive eq (A : Type) (a : A) : A -> Type := | eq_refl : eq A a a.
It’s a family of types, where the only inhabited ones correspond to reflexive equalities.
And then there’s usually some notation shenanigans so we can use = instead 😅
Reserved Notation "_ = _" (at level 70, no associativity). Inductive eq {A : Type} (a : A) : A -> Type := | eq_refl : a = a where "a = b" := eq a b.
The {}
mean that parameter should be inferred automatically, because
it’s constrained/redundant by the types of the later parameters. You
only ever use eq
with values, so A
can always be inferred as the
type of those values.
So, you can only construct things like eq_refl 5 : 5 = 5
. Where that
gets useful is the really funky way that type indexes
interact with
pattern matching- this is “dependent pattern matching”.
4.2.6. TODO Dependent pattern matching
If you have some H : a = b (constructed… somehow, I can get to that 😅) and pattern match on it the only possible branch will be eqrefl a. Except, the type of eqrefl a is a = a. What that means is, on that branch you “learn” (in a way the type checker can understand) that actually b must have been a all along. A similar thing happens if you pattern match on a vector of some generic length- on the Vnil branch you “learn” that that length was actually 0 all along, and on the Vcons branch you learn that the length is actually 1 + some smaller length. That substitution impacts the types of other values you have, refining your understanding of them. For example, say if you have two vectors that share the same length and pattern match on one of them. V1 V2 : Vector A n and you pattern match on V1. Well, on the Vnil branch you learn that n is 0. That also means that on this branch V2’s type is really Vector A 0. Similarly, on the Vcons branch you learn that n is really 1 + m (for some m that you got from the pattern matching). Well, now V2’s type can be refined to Vector A (1 + m) too.
Returning to eq, if you have a V : Vector A n and an H : 5 = n you can actually pattern match on H. There will be exactly one branch, but on that branch you’ll learn that n must really have been 5 all along! And so, you get to refine the type of V to Vector A 5. This all comes down to how the individual constructors get to specify the values of the type indexes they return: when you pattern match and end up on the branch corresponding to that constructor you also learn that actually whatever you had for that index must be interchangeable with whatever that constructor puts there. Otherwise you wouldn’t have made it to that branch, that constructor would not have been usable to construct that value. You can’t construct a Vnil whose length is non-0, and you can’t construct a Vcons whose length is 0. And you can’t construct an eqrefl where a and b are different.
When you pattern match on a value of an indexed type you get to do substitutions on the expression for the index in the types of other stuff.
In V1 V2 : Vector A n, n is an index. So, if you pattern match on V1 you get to substitute n with other stuff in any other types where it appears (like the type of V2)
The thing you get to substitute it with? Well, on each branch the constructor will give an expression of its own for the index. You get to substitute n with that. With eq, the LHS is a parameter and the RHS is an index.
So when you have H : 5 = n, 5 is the param and n is the index.
When you pattern match on it you’ll have exactly one branch where you reveal it must have been constructed by eqrefl and where the index is given as whatever the parameter was.
So you get to substitute n with 5 in any other types.
That’s why, btw, I specifically wrote it 5 = n instead of the other way around. You can write a symmetry helper, but I didn’t want to introduce that yet lol The “primitive” operation is specifically replacing whatever’s on the right side with whatever’s on the left side.
I also did a similar bit of slight of hand using 1 + n in the definition of Vector, for technical reasons that will probably make your eyes glaze over at this stage 😅
5. Backlinks
- Proof writing systems (proofwritingsystems.org)