A Type Checking Algorithm for Higher-rank, Impredicative and Second-order Types

11/13/2017 ∙ by Peng Fu, et al. ∙ 0

We study a type checking algorithm that is able to type check a nontrivial subclass of functional programs that use features such as higher-rank, impredicative and second-order types. The only place the algorithm requires type annotation is before each function declaration. We prove the soundness of the type checking algorithm with respect to System F_ω, i.e. if the program is type checked, then the type checker will produce a well-typed annotated System F_ω term. We extend the basic algorithm to handle pattern matching and let-bindings. We implement a prototype type checker and test it on a variety of functional programs.



There are no comments yet.


page 1

page 2

page 3

page 4

This week in AI

Get the week's most popular data science and artificial intelligence research sent straight to your inbox every Saturday.

1 Introduction

In the paper De Bruijn notation as a nested datatype [1], Bird and Paterson defined a version of generalized fold, which has the following type:

gfoldT :: forall m n b .
            (forall a . m a -> n a) ->
            (forall a . n a -> n a -> n a) ->
       Ψ    (forall a . n (Incr a) -> n a) ->
            (forall a . Incr (m a) -> m (Incr a)) ->
            Term (m b) -> n b

Note that the quantified type variables n and m are of the kind * -> *. Moreover, Term and Incr are type constructors of kind * -> *. Although the type variables n and m have kind * -> *, due to the limitations of the type inference, they cannot be instantiated with second-order types such as \ a . a or \ a . String111Since it is a type level lambda abstraction, we use \ a . a instead of \ a -> a.. As a result, in order to use gfoldT in these situations, one has to duplicate the definition of gfoldT and give it a more specific type. If we have a type checker that supports a limited form of second-order types, then this kind of code duplication can be avoided.

A similar situtation also arises when using impredicative types. We know that in principle all the recursive functions can be defined using a single fixpoint combinator and pattern matching. But the following definition of length2 will not pass the type checker that does not support impredicative types.

fix :: forall a . (a -> a) -> a
fix f = f (fix f)

data Nested :: * -> * where
  NN :: forall a . Nested a
  NCons :: forall a . a -> Nested (List a) -> Nested a

length1 :: forall a . Nested a -> Nat
length1 NN = Z
length1 (NCons x xs) = add one (length1 xs)

length2 :: forall a . Nested a -> Nat
length2 = fix (\ r n -> case n of
                          NN -> Z
                          NCons x xs -> add one (r xs))

Note that the function length1 is counting the number of NCons. This is an example of polymorphic recursion [11], i.e. the recursive call of length1 is at a different type. And length2 is just the fixpoint representation of length1. To type check length2, we would need to instantiate the type variable a in the type of fix with the type forall a . Nested a -> Nat, which is a form of impredicative instantiation. Most type checkers do not support this feature because it is undecidable in general [21]. One way to work around this problem is to duplicate the code for fix and give it a more specific type.

fixLength :: ((forall a . Nested a -> Nat) ->
                  (forall a . Nested a -> Nat)) ->
              (forall a . Nested a -> Nat)
fixLength f = f (fixLength f)
length2 :: forall a . Nested a -> Nat
length2 = fixLength (\ r n -> ...)

For any polymorphic recursive function, we would need this kind of work-around to obtain its fixpoint representation if the type checker does not support impredicative types222This problem was observed by Peyton-Jones et. al. [15].

The goal of this work is to design a type checking algorithm that supports second-order types and impredicative types. One benefit is that it can reduce the kind of code duplications we just mentioned. The main technical contents of this paper are the followings.

  • To accomonadate second-order and impredicative types, we use a specialized version of second-order unification based on Dowek’s work on linear second-order unification ([2],[3]). We called it Dowek’s bidrectional matching algorithm (Section 3.1), it generalizes the first-order unification and the second-order matching. We prove the algorithm is sound and terminating (Appendix 0.B).

  • Armed with Dowek’s bidirectional matching, we describe a type checking algorithm inspired by the goal-directed theorem proving and logic programming (

    [18], [13]). We also develop a mechanism to handle a subtle scope problem. We prove the type checking algorithm is sound with respect to System [5] (Section 3.2, Appendix 0.C). The soundness proof gives rise to a method to generate annotated terms from the input programs, which is implemented in a prototype type checker333The prototype type checker is available at https://github.com/fermat/higher-rank.

  • We extend the basic type checking algorithm to handle pattern matching and let-bindings (Section 4). We test the type checker on a variety of programs that use higher-rank, impredicative and second-order types, these include Bird and Paterson’s program [1] and Stump’s impredicative Church-encoded merge sort [17] (Appendix 0.D).

2 The main idea and the challenges

Consider the following program [15]. Note that we assume the data constructors Z :: Nat and True :: Bool.

data Pair :: * -> * -> * where
  Pair :: forall a b . a -> b -> Pair a b
poly :: (forall v . v -> v) -> Pair Nat Bool
poly f = Pair (f Z) (f True)

If we use Hindley-Milner algorithm ([7], [10]) without using the type annotation for poly, we would have to assume the argument of f has an unknown type x, which eventually leads to a failed unification of Nat -> Nat and Bool -> Bool. Instead, we adopt the well-established goal-directed theorem proving technique founds in most theorem provers (e.g. Coq [18]). To prove the theorem (forall v . v -> v) -> Pair Nat Bool, we first assume f :: forall v . v -> v, then we just need to show Pair (f Z) (f True) :: Pair Nat Bool. We now apply Pair :: forall a b . a -> b -> Pair a b to the goal Pair Nat Bool, this resolves to two subgoals f Z :: Nat and f True :: Bool. We know these two subgoals holds because we have f :: forall v . v -> v.

In general, to type check a program with a type , we will type check assuming , where the type variable in and behaves as a constant (called eigenvariable). To type check a program with the type , where , we will first unify with (the type variable in behaves as free variable), obtaining a unifier . Then we will type check for . Notice the different behaviors of the quantified type variable in the two cases. When a type variable is introduced as an eigenvariable, we call the introduction type abstraction, when a type variable is introduced as a free variable, we call the introduction type instantiation. Although this idea of type checking works perfectly for the poly example, it is not obvious how it can be scale to a more general setting. Indeed, we will need to address the following problems.

  • Finding an adequate notion of unification. Impredicative polymorphism means that a type variable can be instantiated with any type (which includes forall-quantified types). Consider the following program.

    data List :: * -> * where
      Nil :: forall a . List a
      Cons :: forall a . a -> List a -> List a
    test :: List (forall a . List a)
    test = Nil

    To type check test, we need to unify List a and List (forall a . List a), which is beyond first-order unification as the forall-quantifed type forall a . List a is not a first-order type. First-order unification can not work with second-order types neither, consider the following program.

    data Bot :: * where
    data Top :: * where
    k1 :: forall p . p Bot -> p Top
    k1 = undefined
    k2 :: forall p . p Top -> p Top
    k2 = undefined
    a1 :: Bot -> Top
    a1 = k1
    a2 :: Top -> Top
    a2 = k2

    Note that the type variable p in k1, k2 is of the kind * -> *. We should be able to type check a1 by instantiating the type variable p in k1 with the type identity \ a . a. This would require unifying (p Bot) and Bot, which is an instance of undecidable second-order unification [6]. Besides the problem of undecidability, second-order types also raises a concern of type ambiguity. For example, to type check a2, we can again instantiate the type variable p in k2 with the type identity \ a . a, but nothing prevents us to instantiate p with the type constant function \ a . Top. Thus there can be two different type annotations (derivations) for a2.

    Our approach. Following the usual practice in higher-order unification [3], the unifier of and is the unifier of and , provided the variable is a fresh eigenvariable and does not appears in the codomain of . To handle second-order types, we use a decidable version of second-order unification due to Dowek [2], it generalizes first-order unification and second-order matching.

    Drawback. The unification algorithm we use could generate multiple (finitely many) unifers when there are second-order type variables. This implies that there may be multiple successful typing derivations for a program when it uses second-order type variables. For the purpose of type checking, it is enough to pick the first successful derivation because all the typing annotations will be erased when we run the program. If all the derivations fail, then the type checking fails. So second-order types will introduce a kind of nondeterminism during type checking.

  • Handling type abstraction. We know that it is safe to perform type abstraction when we are defining a polymorphic function that has at least one input. For the other situations, it is not straightforward to decide at which point to perform type abstraction. A common decision is always perform type abstraction for the outermost forall-quantified variables. Consider the following program.

    data F :: * -> * where
    fix :: forall a . (a -> a) -> a
    fix f = f (fix f)
    l :: forall x . F x -> F x
    l = undefined
    l’ :: (forall x . F x) -> (forall x . F x)
    l’ = undefined
    test1 :: forall y . F y
    test1 = fix l
    test2 :: forall y . F y
    test2 = fix l’

    The program test1 can be type checked by first abstracting the outermost variable y, then we need to type check fix l with the type F y (with y as an eigenvariable). This is the case because we can instantiate the type variable a in the type of fix with F y, and instantiate the quantified variable x with y in the type of l. On the other hand, to type check the program test2, we must not perform type abstraction.

    Our approach. To type check both test1 and test2, we decide to branch the type checking when checking an application (which includes single program variable or constructor) against a forall-quantified type. Our type checker always performs type abstraction when checking a polymorphic function that has at least one input. For example, when checking program such as f x .. = e with the type f :: forall a . T, then we would abstract the outermost type variable a. But when checking a application against a polymorphic type, the type checker will make two branches, in one branch the type checker will perform type abstraction and in the other the type checker does not. For example, when checking f g with the type forall a . T, we would check both f g against forall a . T and f g against T.

    Drawback. Our decision on checking an application against a forall-quantified type also introduce nondeterminism. When checking single program variable or constructor against a polymorphic type, branching is at no cost as these are just two additional leaves. But in the other cases branching does mean the type checker will do extra work.

  • Scope management. Consider the following program.

    k1 :: forall q . (forall y . q -> y) -> Bot
    k1 = undefined
    k2 :: forall x . x -> x
    k2 = undefined
    test :: Bot
    test = k1 k2

    The program test appears to be well-typed, as we can instantiate the variable q in the type of k1 with y, then we can apply k1 to k2. But this is not the case because q is incorrectly referred to the bound variable y. When using our algorithm to check k2 against forall y . q -> y (q is a free variable), in one branch the algorithm will try to unify x -> x with forall y . q -> y, which fails. In another branch, the algorithm will perform type abstraction, i.e. it will check k2 against the type q -> y (y is an eigenvariable). Since x -> x unifies with q -> y (the unifier is ), without proper scope management, our algorithm will wrongly report the success on the second branch.

    Our approach. To handle the scope problem, we introduce a notion of scope value for variables. Informally, when each variable (free variable or eigenvariable) is first introduced, it will be assigned a scope value. A variable introduced later will have a scope value larger than a variable introduced earlier. When a free variable is substituted by a type , we require all the eigenvariables in to have a smaller scope value compared to ’s, i.e. can only refer to the eigenvariables that are introduced before . So in our example, when type checking test, the scope value for the free variable q will be and the scope value for the eigenvariable y will be , which is larger than , hence the substitution gives rise to a scope error. We incorporate a scope checking process into the type checking algorithm, which is essential for the soundness of the type checking.

    Drawback. When a free variable is substituted by a type , other than eigenvariables and constants, may contain free variables. The question now is what if these free variables have scope values larger than ’s. For example, suppose the scope value for is , but contains a free variable with scope value . We allow such substitution, but we need to update the scope value of to the smaller value , this is to prevent (and indirectly) later refer to any eigenvariable with the scope value . Thus when a unifier is generated, we need to perform scope value check as well as updating the scope values. This complicates the presentation of the type checking algorithm, but we manage to prove that the scope checking and updating ensures soundness.

3 A type checking algorithm for impredicative and second-order types

We describe a type checking algorithm for higher-rank, impredicative and second-order types in this section. Higher-rank types means the forall-quantifiers can appear anywhere in a type. Impredicative types means type variables can be instantiated with any types (includes the forall-quantified types). Second-order types means type variables of kind can be instantiated with the lambda-abstracted types. All of these features are available in System , which will be the target language for our type checking algorithm. Note that the type checking problem for System with annotations is decidable. We use the terminology proof checking to mean checking with annotations, and we use the terminology type checking to mean giving a type and a unannotated term , construct an annotated term in such that it can be proof checked with type and can be erased to . Thus our type checking algorithm will always produce an annotated term if the type checking is successful.

Annotated Expressions

Unannotated Expressions



Type Environment

Type Equivalence

Γ⊢(x—c) : T(x—c) : T ∈Γ Γ⊢p_1 p_2 : TΓ⊢p_1 : T’ →T Γ⊢p_2 : T’ Γ⊢λx : T’. p : T’ →TΓ, x : T’ ⊢p : T
Γ⊢λa . p: ∀a . TΓ⊢p : T a ∉FV(Γ) Γ⊢p T’ : [T’/a]TΓ⊢p : ∀a . T Γ⊢p : T’Γ⊢p : TT = T’
Figure 1: System

We recall the standard System in Figure 1. We use to denote term constant, to denote the type constants and to denote the type variables. We use to mean all the free type variables in the environment . Since System enjoys type level termination, we only need to work with normal form of a type. Note that the kind inference for is decidable and we only work with well-kinded types444The kinding rules for is available in Appendix 0.A.

3.1 Bidirectional second-order matching

For the purpose of type checking and unification, we make the distinction between eigenvariables and free variables for the type variables. The type variable that can be substituted during the type checking or unification process is called free variables, the variable that cannot be substituted is called eigenvariables.

We use to denote the set of free variables in and to denote the set of eigenvariables in . We use as a predicate to denote the apartness of two sets and means that if and , then . We write to means the free variables in the codomain of is disjoint with its domain. We say a type variable is first-order if it is of kind .

Definition 1 (Dowek’s bidirectional second-order matching)

Let denote , where are fresh free variables. Let denote the -th projection . Let denotes a set of eigenvariables and denotes a set of unification problem .

We formalize the bidirectional second-order matching as a transition system from to in Figure 2. If , where and , then we say the bidirectional matching is successful (denoted by ), otherwise it fails555Here stands for identity substitution..

, if is first-order, and .
where is a fresh eigenvariable.
where .
Figure 2: Bidirectional second-order matching

The bidirectional matching algorithm in Figure 2 is similar to the standard second-order matching, but it is bidirectional due to the exchange rule . Moreover, unlike standard second-order matching, in the rules and , we do not perform substitution on , this ensures the termination of the transition system. We also add the rule to handle the forall-quantified types. The bidrectional second-order matching is sound and terminating. The rules and are overlapped, so there can be multiple unifiers for a given unification problem.

Theorem 3.1 (Termination and Soundness666The proof is at Appendix 0.b)

The transition system in Figure 2 is terminating. Moreover, if , then .

Example 1

Consider the unification problem of and . They should not be unified. Indeed it will not be a successful becase we will have the following transition:

But is not a unifier because its codomain is not apart from .

Example 2

Consider the unification problem of and . The first step of the transition is: . Then there will be the following four possible transitions, but only the last one is successful.





3.2 The type checking algorithm

Let be a list of pairs , where is a type variable (free variable or eigenvariable) and . We call such a scope value. We write to mean the scope value of , when , is defined to be an arbitrary large value. For a set of variables , we write to mean the set of its scope values in . We define to be the maximum scope value in , if is empty, then we set . The following definition of scope check ensures that the free variables can only be substituted with the types that contains eigenvariables that are introduced before.

Definition 2 (Scope check)

We define to be the following predicates: For any , if , then for any , we have and .

Let and . The following definition of will replace the pair (where ), by the pairs , where and is the minimal one among and . We use to mean append . We write to means a scope environment that has the same variables as , but if a variable has multiple scope values in , then it will have the minimal one in .

Definition 3 (Updating)

Let and . We define .

Let . The tuple means is an unannotated program to be type checked with the type under the scope environment and the typing environment . The tuple means the type checking process for a branch is finished, with the final scope and typing environment and . We now define the type checking algorithm as a transition system between . We write to mean applying to all the types in . We use to mean a program variable or a data constructor . Furthermore, means .

Definition 4 (A type checking algorithm)

  • if and . Here is defined by the followings.

    • .

    • . Note that are the fresh free variables in .

  • ,

    where , , and are the fresh eigenvariables in .

  • ,

    where , , and . Here is defined by the following.

    • , where are fresh free variables.

    • , . Note that are the fresh free variables in .

  • ,

    where , and . Here is defined by the following.

    • . Note that are the fresh free variables in .

Note that is defined as followings.

, where .

, where .

The transition system is defined over the structure of and , hence it is terminating. To type check with under the environment , the initial state will be . We say the type checking is successful if the final state is of the form , where .

There are two kinds of nondeterminism going on in the transition system in Definition 4. One is due to our decision on handling type abstraction, i.e. the transition will be branching when we check an application against a forall-quantified type. This means the rule is overlapped with rules when , and it is overlapped with and when . Another kind of nondeterminism is due to the appearance of the second-order type variables, so the rules could leads to multiple states, as the bidirectional matching can generate multiple valid unifiers.

Each of the transitions will generate a substitution , which will be checked by the predicate against the current scope value environment . Then is extended to with some new free variables, and this will be updated to a new scope environment , which will contain all the free variables that appear in the environment .

The rule is for handling the variable and the constant case. The rule is solely responsible for the type abstraction of the outermost forall-quantified variables. When checking a lambda-abstraction against a forall-type, it is only natural to perform type abstraction. This is why rule is also removing the lambda-abstractions after the type abstraction.

In general, a function of a type does not always have input. The rule accounts for the partial application, while The rule accounts for the over application. For example, it seems can only take one input, but we know is typable with , this is why we need the rule to provide additional free type variables for later instantiation (i.e. the free variables in the rule ).

3.3 Soundness and examples

We prove the type checking algorithm is sound. The proof gives rise to an algorithm that produces an annotated program if the type checking is successful.

Theorem 3.2 (Soundness777The proof is at Appendix 0.c)

If , where and , then there exists a in such that and 888Here means erasing all the type annotations in ..

Example 3

Consider the following Church encoded numbers.

type Nat :: * = forall x . (x -> x) -> x -> x
zero :: Nat
zero = \ s z -> z
succ :: Nat -> Nat
succ n = \ s z -> s (n s z)
add :: Nat -> Nat -> Nat
add n m = n succ m

To type check add, let and the initial state be . We have a successful and a failed transition in Figure 3. Note that is an abbreviation of . A branching occurs at the state , where we can apply either or , the former will lead to a successful transition, while the latter will fail because cannot be unified with .



Figure 3: The type checking transition of Example 3
Example 4

Let . To type check with type , let the initial state be . We will have the following two unsuccessful transitions:



In the first transition, the step can not be performed because is not unifiable with . In the second transition, the step can not be performed because the unifier of and is , but the predicate is false because refers to the eigenvariable , which is introduced later than .

3.4 Discussion

The type checking algorithm cannot type check beta-redex, i.e. any programs of the form , because it cannot infer a type for . So programmer will have to restructure the program as and supply type annotations for . We argue that this is not a serious problem because most programs do not contain explicit beta-redex (See also the programs examples in the Appendix 0.D).

Although the order of the tuples in does not matter for the soundness proof, it does matter in practice. The transitions could generate multiple new tuples, how are we going to decide in what order to check these tuples? We could try all the possible combinations, but this is not very efficient. So in the prototype implementation we use a measure to arrange the order of tuples generated by . The measure is the number of implications in the goal in a tuple , the more implications it has, the higher priority we will give to check this tuple (as it may provide more useful information that we can use later). For example [15], let . Here is a type constructor. Consider the following transition.

Here is a free variable that is introduced when we instantiate the type of . If we try to type check the tuple first, we will stuck because no rule apply to this tuple. But if we type check the tuple first, we will obtain the new information, i.e. will be instantiated with , as a result, we can type check the tuple

. This example fits the heuristic that the type

gives more information than the type since it has more implications.

4 Extensions and implementation

In order to show the type checking algorithm works for a nontrivial subclass of functional programs, we extend it to work with let-bindings and pattern matching. First we extend the unannotated expression, . Here stands for an index set and stands for the pattern . The following are the rules for checking let-bindings and pattern matching. The idea is that we can use fresh free variables as goals to enable the algorithm to perform a limited degree of inference.

Definition 5 (Extensions)
  • , where is a fresh free variable and .

  • .