h5i

Chapter 5

A Type Checking Algorithm

The typing rules say which judgments hold, but not how to decide whether they hold. This chapter turns those rules into an algorithm by splitting the work between two mutually recursive functions: one computes the type of a term, while the other compares that type with the expected one using definitional equality. The result is a few dozen lines of Rust.

From rules to an algorithm

Seven of the eight rules of Figure 3.1 are syntax-directed: looking at the term tells you which rule applies. A universe triggers T-Sort, a variable T-Var, a Π\Pi T-Pi, a λ\lambda T-Lam, an application T-App. T-Conv is the exception. It can be applied to any term at any point, so a program cannot tell from the term when to use it, and a naive search for a derivation would have to guess.

The standard solution [Barendregt 1992] is to split type checking into two functions:

  • Infer(Γ,t)\Infer(\Gamma, t) computes the type of tt in Γ\Gamma, following the syntax-directed rules, or fails.
  • Check(Γ,t,T)\Check(\Gamma, t, T) verifies that tt has the expected type TT: it infers a type and compares it with TT using definitional equality.

T-Conv is applied in exactly one place, inside Check\Check. There are two further places where a type must have a particular shape, a Π\Pi in T-App and a universe in the premises of T-Pi and T-Lam. There the algorithm computes the inferred type just far enough to see its outermost form.

The algorithm uses two functions on terms whose definitions are postponed to Chapter 12:

  • Whnf(T)\Whnf(T) computes TT until its outermost shape is visible. For example, Whnf(Id  (P→P))=P→P\Whnf(\mathit{Id}\;(P \to P)) = P \to P, which is a Π\Pi.
  • IsDefEq(A,B)\IsDefEq(A, B) decides A≡BA \conv B.

The algorithm

Algorithm 5.1 is written in pseudocode close to the Rust. Each case of Infer\Infer is annotated with the rule it implements.

Algorithm 5.1. Type inference and checking.

Infer(Γ, t):
  if t = Sort u:                                        -- T-Sort
      return Sort (u + 1)
  if t = x:                                             -- T-Var
      if (x : A) ∈ Γ: return A  else fail "unbound variable"
  if t = Π(x : A). B:                                   -- T-Pi
      u ← InferSort(Γ, A)
      (x', B') ← Freshen(Γ, x, B)
      v ← InferSort((Γ, x' : A), B')
      return Sort (imax(u, v))
  if t = λ(x : A). s:                                   -- T-Lam
      InferSort(Γ, A)
      (x', s') ← Freshen(Γ, x, s)
      B ← Infer((Γ, x' : A), s')
      return Π(x' : A). B
  if t = f a:                                           -- T-App
      T ← Whnf(Infer(Γ, f))
      if T = Π(x : A). B:
          Check(Γ, a, A)
          return B[x ↦ a]
      else fail "not a function"

Check(Γ, t, T):                                         -- T-Conv
  T' ← Infer(Γ, t)
  if IsDefEq(T', T): return  else fail "type mismatch"

InferSort(Γ, A):
  T ← Whnf(Infer(Γ, A))
  if T = Sort u: return u  else fail "expected a type"

Freshen(Γ, x, b):
  if x ∈ dom(Γ):
      choose x' ∉ dom(Γ) ∪ FV(b)
      return (x', b[x ↦ x'])
  else return (x, b)

The algorithm has the following properties.

  • Syntax-directed. Each case of Infer\Infer corresponds to exactly one rule of Figure 3.1. Finding a proof may be hard, but checking a given proof is mechanical: there is never a choice to make.
  • T-Conv is never applied on its own. It happens inside Check\Check, the only place where an inferred type meets an expected one.
  • InferSort\InferSort is how the kernel decides whether a term is a type. A term AA is a type exactly when its type computes to a universe. A λ\lambda-abstraction, for instance, is never a type, because its inferred type is always a Π\Pi.
  • Freshen\Freshen implements the side condition of C-Ext. It renames a binder whose name is already in the context. Chapter 11 shows a term that makes the kernel unsound without this step.
  • Contexts stay well formed. Starting from the empty context, every context the algorithm builds is well formed, because an assumption is added only after InferSort\InferSort has succeeded on its type. A caller that starts from a non-empty context must check that context separately.
  • Termination depends on Whnf\Whnf and IsDefEq\IsDefEq terminating, which holds for well-typed terms by strong normalization (Chapter 14).

In the code

the complete infer function (about fifty lines)
pub fn infer(&self, ctx: &Context, t: &Term) -> Result<Term> {
    match t {
        // T-Sort
        Term::Sort(u) => Ok(Term::Sort(u.checked_add(1).ok_or("universe overflow")?)),
        // T-Var
        Term::Var(x) => ctx
            .iter()
            .rev()
            .find(|(name, _)| name == x)
            .map(|(_, ty)| ty.clone())
            .ok_or_else(|| format!("unbound local: {x}")),
        // T-Const (Chapter 7)
        Term::Const(c) => self
            .decls
            .get(c)
            .map(|decl| decl.ty.clone())
            .ok_or_else(|| format!("unknown constant: {c}")),
        // T-Pi: Sort 0 is impredicative.
        Term::Pi(x, domain, body) => {
            let domain_level = self.infer_sort(ctx, domain)?;
            let (x, body) = rename_binder_if_needed(ctx, x, body);
            let mut extended = ctx.clone();
            extended.push((x, (**domain).clone()));
            let body_level = self.infer_sort(&extended, &body)?;
            Ok(Term::Sort(if body_level == 0 {
                0
            } else {
                domain_level.max(body_level)
            }))
        }
        // T-Lam
        Term::Lam(x, domain, body) => {
            self.infer_sort(ctx, domain)?;
            let (x, body) = rename_binder_if_needed(ctx, x, body);
            let mut extended = ctx.clone();
            extended.push((x.clone(), (**domain).clone()));
            let body_type = self.infer(&extended, &body)?;
            Ok(Term::Pi(x, domain.clone(), Box::new(body_type)))
        }
        // T-App
        Term::App(f, a) => match self.whnf(&self.infer(ctx, f)?)? {
            Term::Pi(x, domain, codomain) => {
                self.check(ctx, a, &domain)?;
                Ok(subst(&codomain, &x, a))
            }
            ty => Err(format!("application of non-function: {ty:?}")),
        },
    }
}

A few details differ from the pseudocode.

  • The context lookup in the variable case searches from the end (.rev()). Because rename_binder_if_needed keeps names distinct, there is never more than one match, but searching from the end is the right behaviour if a caller passes a context with repeated names.
  • The imax\imax of T-Pi is the if body_level == 0 expression: a Π\Pi whose body is a proposition is a proposition, otherwise it lives in the maximum of the two levels.
  • The universe level is a u64 and checked_add guards against overflow. No real proof comes anywhere near level 2642^{64}, but a kernel should not have undefined behaviour on any input.
  • The application case calls whnf on the inferred type of f before matching on it, as the pseudocode does. Without this, a function whose type is written as Id  (A→B)\mathit{Id}\;(A \to B) could not be applied.

The remaining two functions are short.

/// Gamma |- t : expected, using T-Conv to compare the inferred type.
pub fn check(&self, ctx: &Context, t: &Term, expected: &Term) -> Result<()> {
    self.infer_sort(ctx, expected)?;
    let actual = self.infer(ctx, t)?;
    if self.is_def_eq(&actual, expected)? {
        Ok(())
    } else {
        Err(format!("type mismatch: actual {actual:?}, expected {expected:?}"))
    }
}

/// Infers the type of t and requires it to be a universe Sort u. Returns u.
pub fn infer_sort(&self, ctx: &Context, t: &Term) -> Result<u64> {
    match self.whnf(&self.infer(ctx, t)?)? {
        Term::Sort(u) => Ok(u),
        other => Err(format!("expected a type, got {other:?}")),
    }
}

check begins with infer_sort(ctx, expected). That is the premise Γ⊢B:Sort u\Gamma \vdash B : \Sort{u} of T-Conv: the expected type must itself be a type. The pseudocode omits it because callers inside the algorithm always pass types that have already been checked, but the public check is also called with user-supplied statements, so the Rust version verifies it.

Finally, Freshen:

fn rename_binder_if_needed(ctx: &Context, x: &str, body: &Term) -> (String, Term) {
    if !ctx.iter().any(|(name, _)| name == x) {
        return (x.into(), body.clone());
    }
    let mut used = all_names(body);
    for (name, ty) in ctx {
        used.insert(name.clone());
        used.extend(all_names(ty));
    }
    let renamed = fresh(x, &used);
    let renamed_body = subst(body, x, &var(&renamed));
    (renamed, renamed_body)
}

The set of names to avoid is larger than the pseudocode's dom(Γ)∪FV(b)\dom(\Gamma) \cup \FV(b): it includes every name appearing anywhere in the body and in the context's types, bound or free. Avoiding more names than necessary is always safe, and it keeps the renamed term readable. fresh appends primes until the name is unused, so a clash on x produces x'.

Watching Infer run

The explorer below runs the algorithm on a term and records every recursive call: which case of Infer\Infer it entered, in which context, and what it returned. The notes report the hidden work, such as Whnf\Whnf exposing a Π\Pi or Freshen\Freshen renaming a binder. The context field accepts assumptions such as Q : Sort 0; q : Q, separated by semicolons.

How a proof is checked

To verify that a term tt proves a theorem TT, the kernel runs Check(⋅,t,T)\Check(\cdot, t, T) in the empty context. In the script language this is what qed does: the tactics of Chapter 10 assemble a term, and qed hands it to check together with the statement given by goal. Everything between is untrusted.

The next chapter runs Infer\Infer and Check\Check by hand on several terms, including one that is well typed but does not prove what it claims to.

Exercises

Trace Infer(⋅,λ(A:Sort 1). λ(a:A). a)\Infer(\cdot, \tLam{A}{\Sort{1}}{\tLam{a}{A}{a}}) and give the result and its universe.

Solution

InferSort(⋅,Sort 1)\InferSort(\cdot, \Sort{1}) returns 22. The context becomes A:Sort 1A : \Sort{1}. For the inner λ\lambda, InferSort(A:Sort 1,A)\InferSort(A : \Sort{1}, A) returns 11, the context becomes A:Sort 1,a:AA : \Sort{1}, a : A, and Infer\Infer returns AA for the body. So the inner result is Π(a:A). A\tPi{a}{A}{A} and the outer result is Π(A:Sort 1). Π(a:A). A\tPi{A}{\Sort{1}}{\tPi{a}{A}{A}}. Running InferSort\InferSort on that type gives imax(2,imax(1,1))=2\imax(2, \imax(1, 1)) = 2, so the type lives in Sort 2\Sort{2}.

Why does the Π\Pi case of Infer\Infer call Freshen\Freshen even though a Π\Pi-type is "just a type"? Give a term where skipping it would change the result.

Solution

The body of a Π\Pi is checked in an extended context, so the same shadowing problem arises as for λ\lambda. For Γ=x:Sort 0,y:x\Gamma = x : \Sort{0}, y : x, the type Π(x:Sort 1). y\tPi{x}{\Sort{1}}{y} should fail, because y:xy : x where xx is the outer proposition and a proposition is not a universe. Without renaming, the inner x:Sort 1x : \Sort{1} would be found first by the reversed lookup, making yy's type appear to be Sort 1\Sort{1}-valued, and the result would be wrong. With renaming the inner binder becomes x′x' and the body still refers to the outer xx.

What to remember

  • Infer\Infer follows the shape of the term; Check\Check infers and then compares with IsDefEq\IsDefEq. T-Conv lives only inside Check\Check.
  • Whnf\Whnf exposes the outermost shape of a type where a Π\Pi or a universe is required.
  • InferSort\InferSort is the test "is this term a type?": its type must compute to a universe.
  • Freshen\Freshen renames binders that would shadow a context entry. It is a correctness requirement, not a convenience.
  • src/typecheck.rs is a direct transcription, about one hundred lines.