Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A Type Checker

The previous chapter typed expressions built from literals, operators, and if. A program also names things, and its functions take parameters whose types it may not write down. This one prints total: 80. price and label are bound by let; double is a lambda whose parameter n carries no type annotation; to_string is the prelude’s conversion of a value to its text:

fn main() {
    let price = 40;
    let label = "total: ";
    let double = |n| n * 2;
    println(label ++ to_string(double(price)));
}

Two shorter programs are rejected before they run, each with the same message:

fn main() {
    let xs = [];
    println(len(xs));     // the type of this expression cannot be determined
}

fn main() {
    let id = |x| x;
    println(1);           // the type of this expression cannot be determined
}

The programs raise these questions:

  • What type does price have where it is used? The rules so far type a literal and an operator. price is a name, and its type was settled two lines earlier.
  • What type does n have? Nothing annotates it. The body n * 2 says something about it, and the call double(price) says what is passed to it, but the lambda is checked before the call is reached.
  • When does a program not say enough? [] is a list of something, and |x| x returns whatever it is given. What would a checker need to know to give either one a type, and where would it look?

The rules of Appendix D answer the first question directly. The second and third are about how a program computes a type that no rule hands it, and the method came from automated theorem proving.

Solving for types

J. A. Robinson’s “A Machine-Oriented Logic Based on the Resolution Principle” (1965) is a method for proving theorems by machine, and at its centre is a procedure for making two logical expressions identical by substituting for their variables. §5.7 defines the idea: “θ is said to unify A, or to be a unifier of A, if Aθ is a singleton”, that is, if applying the substitution θ to every expression in the set A leaves only one expression. §5.8 gives “the Unification Algorithm”. It finds the first position at which the expressions disagree and, in its third step, substitutes for a variable there, with a condition: “If Vk is a variable, and does not occur in Uk, set σk+1 = σk{Uk/Vk}, add 1 to k, and return to step 2. Otherwise, terminate.” The clause “and does not occur in Uk” is what is now called the occurs check.

Robin Milner’s “A Theory of Type Polymorphism in Programming” (1978), whose guarantee the previous chapter quoted, brought the procedure to types. §4.1: “𝒲 is based on the unification algorithm of Robinson [14].” Milner’s algorithm 𝒲 gave each unknown type a variable and solved the equations that the program’s operations imposed on them. He credited the idea to the logician Roger Hindley, who had derived types for terms of combinatory logic: “Hindley appears to have been the first to notice that the Unification Algorithm of Robinson [14] is appropriate to this problem.”

Benjamin Pierce and David Turner’s “Local Type Inference” (2000) asked how much inference programmers need. Their answer was that “it is acceptable to require annotations on the bound variables of top-level function definitions (since these usually provide useful documentation)”, and they recovered the remaining types by passing them between neighbouring nodes of the syntax tree, in two modes, “synthesis mode, where typing information is propagated upward from subexpressions, and checking mode, where information is propagated downward from enclosing expressions.” Their inference was local by design, working “without long-distance constraints such as unification variables”. They did not claim the modes as their invention: “The basic idea of bidirectional checking is well known as folklore.”

Bridger’s checker draws on both. It has Pierce and Turner’s two modes and their annotated top-level signatures, and within a declaration it has Milner’s inference variables, solved by Robinson’s procedure.

Typing in a context

A variable’s type depends on where it appears, as its value did. The evaluator answered “what is x?” while the program ran, with the environment , which maps each name in scope to a value. The checker answers it before the program runs, with a typing context , which maps each name in scope to a type. The judgment is now the one Appendix D writes,

read in context , expression has type . The rules of the previous chapter carry through unchanged; a literal’s type does not depend on it. A variable has the type its binding gave it, and a let extends the context for the rest of its block, as extended the environment:

is with x bound to , shadowing any earlier x, so a block that rebinds x as a String gives the inner x that type and leaves the outer one an Int outside the block. In the opening program binds price : Int and label : String, and reads them back at their uses.

The checker builds from the program text, before anything runs, and it can do so because Bridger’s scope is lexical: the binding a name refers to is fixed by where the name is written. As Static vs. Dynamic Scope observed, a dynamically scoped name would get its binding from whichever caller was running, and no context built from the text could say what type it had.

also carries the enclosing function’s return type, , so that a return can be checked against it. Because control leaves at a return, the expression itself produces no value where it stands, and the rule lets it have any type at all; that is what lets return 0 sit in one branch of an if whose other branch is a String:

The reference cells and loops of Part IV have rules of the same kind: ref e has type when e has type , deref undoes it, an assignment and a loop have type (), and a for binds its variable at the list’s element type for its body. The reference gives each.

Function types

A function value has a type that records what it takes and what it gives back. A lambda is typed by typing its body in a context extended with its parameters, and a call is typed by matching its arguments against the parameters of its callee’s type:

(The rules write for Bridger’s |x1, …, xn| e, as the reference does, and the reference’s also sets to for a return inside the lambda.) The opening program’s double gets , and then accepts double(price) because price : Int.

The types in this chapter are monomorphic: each function type has particular types in it, and a lambda has one of them. The types a function written once can take at many types, and the top-level declarations whose signatures make them possible, are the subject of the next chapter.

From rules to an algorithm

does not say where comes from. Read as a rule, it says that if some makes the body well-typed, the lambda has a function type with in it, and it is silent about how to find one. For |n| n * 2 the right choice is Int, but the rule only confirms the choice once it has been made. A checker is a program and cannot guess. It has two ways to proceed without guessing: take the type from the surrounding context when the context has one, or give the unknown type a name and solve for it from what the rest of the declaration requires.

Checking and inferring

Some positions in a program already know what type they need. The body of fn f(c: Bool) -> Int needs an Int; the argument of a call to a function whose parameter is an Int needs an Int; the else branch of an if needs the type the then branch had. Other positions know nothing in advance: the right-hand side of an unannotated let, or a lambda whose type is about to be discovered.

A bidirectional checker splits the judgment into two modes, one for each kind of position. reads synthesizes : the checker works out a type from alone. reads checks against : the type is given, and the checker confirms that has it. A variable synthesizes the type gives it. A call synthesizes its type from its callee and checks each argument against the parameter type it is passed to. A lambda checks against a function type, taking its parameter types from it:

In the parameter types are inputs, read off the function type the lambda is checked against, and nothing is guessed. One more rule connects the modes. An expression that synthesizes a type checks against any type equal to it:

Bridger’s apply shows a lambda checked against a type its context supplies:

fn apply(f: fn(Int) -> Int, x: Int) -> Int = f(x);

fn main() {
    println(apply(|n| n + 1, 5));                       // 6
}

checks the argument |n| n + 1 against apply’s first parameter type, , and gives n the type Int from it.

Inference variables

The modes do not cover every case. let double = |n| n * 2; has no type for its right-hand side to check against, so the lambda must synthesize, and synthesis needs a type for n before the body can be typed. A checker that cannot guess n’s type can name it. It gives n a fresh inference variable , a placeholder that stands for a type not yet known, and carries on. Typing the body then produces requirements on :

  1. n * 2 is an arithmetic operation, so each operand is checked against Int.
  2. n synthesizes , and requires .
  3. The requirement is met by solving as Int. From then on, wherever appears, it means Int.
  4. The body synthesizes Int, so the lambda synthesizes , which is .

double("a") is then rejected at "a", with expected Int, found String: by the time the call is checked, has been solved, and checks the argument against Int.

The equation in the second step is what ’s premise becomes once types can contain variables. Satisfying it, by choosing types for the variables that make the two sides equal, is unification. It compares two types structurally:

  • Two base types unify if they are the same type: Int with Int, Bool with Bool.
  • Two types built by the same constructor unify if their parts do, pairwise: two lists if their element types unify; two function types if they take as many parameters, each parameter type unifies with the corresponding one, and the result types unify; tuples and reference types the same way.
  • A variable already solved unifies as its solution would.
  • An unsolved variable unifies with a type by being solved as .
  • Anything else, such as Int with String or a list with a function type, is a mismatch.

The solution unification produces assigns only what the requirements force. Solving as Int in double follows from n * 2; nothing else would make the body well-typed, and nothing more is assumed.

let xs = []; gives the empty list the type with a fresh , since no element says what it holds. In len(xs ++ [1]) the concatenation unifies with and solves as Int. In len(xs) nothing does, and when the checker finishes the declaration is still unsolved. The type of [] is then not determined by the program, and Bridger rejects it, asking for an annotation such as let xs: [Int] = [];. let id = |x| x; with no use is the same case: its type is , and nothing fixes .

The occurs check

Recursion and higher-order functions promised that this checker would reject self-application with the error cannot construct the infinite type. The smallest case is

fn main() {
    let w = |x| x(x);
    println(1);
}

x gets a fresh variable , and the body’s result a fresh . The body calls x, so must be a function type. The call passes x itself as the argument, so that function type’s parameter is , and unification is asked to solve

Solving as would make every inside that type stand for again, and spelling the solution out would substitute without end: no type written with finitely many fns satisfies the equation. Unification therefore adds one condition to its rule for an unsolved variable. Before solving as , it checks that does not occur in , looking through any variables already solved, and reports an infinite type if it does. This is the occurs check. The M4 evaluator runs the program, since w is never called; the checker rejects it, at x(x).

Bridger’s checker

Bridger’s checker is bidirectional, with inference variables for what neither mode supplies, and the inference it does stays inside one declaration. A function body is checked against the signature its declaration writes, a lambda’s parameter types come from an annotation, from the type it is checked against, or from a fresh variable, and every variable a declaration introduces must be solved by the time the declaration has been checked. No inference variable outlives its declaration, so a type left open in one declaration cannot be fixed by a use in another. The next chapter takes up what the signatures at those boundaries can say.

The checker leaves the program’s AST as the parser produced it. The type of each expression goes into a separate table, the TypeMap, keyed by the expression’s span; the determination rule reads it back, tests inspect it, and the evaluator never sees it.

Building it

The checker’s arms are the typing rules, one per form of Expr, as the evaluator’s arms were the evaluation rules. The provided method you fill in is

pub fn check_expr(
    &mut self,
    e: &Expr,
    ctx: &Ctx,
    expected: Option<&Ty>,
) -> Result<Ty, TyError>

and its expected argument is the two modes in one parameter. With Some(ty) the arm checks e against ty; with None it synthesizes. It returns e’s type either way, and records it at e’s span. An arm with no use for an expected type synthesizes one and then unifies it with the expected type, which is . An arm that has a use for it passes it on: a lambda reads its parameter types from an expected function type, and an if checks its else branch against the type its then branch has.

Ctx is , and it is built like the environment of Part III: immutable, with extend returning a new context and leaving the old one as it was.

impl Ctx {
    /// The type bound to `x`, searching outward through enclosing scopes.
    pub fn lookup(&self, x: &str) -> Option<Ty>;
    /// Bind `x : ty` in a fresh child scope and return it.
    pub fn extend(&self, x: Name, ty: Ty) -> Ctx;
    /// A lambda body: bindings stay visible, `return` targets `ret`
    /// (`self_ty` is for methods, Part IX).
    pub fn enter(&self, ret: Option<Ty>, self_ty: Option<Ty>) -> Ctx;
    /// The enclosing function's return type, if inside one.
    pub fn ret(&self) -> Option<Ty>;
}

The inference variables are provided too. fresh_meta makes one, solve records a solution, resolve replaces every solved variable in a type by its solution, and record puts a type in the TypeMap:

impl Checker {
    pub fn fresh_meta(&mut self) -> Ty;
    pub fn solve(&mut self, id: u32, ty: Ty);
    pub fn resolve(&self, ty: &Ty) -> Ty;
    pub fn record(&mut self, span: Span, ty: Ty);
}

The arms, and unify with its occurs check, are yours. In the starter each is a milestone hole naming the rules it implements:

// M5: one arm per typing rule, over the M1–M4 expressions
Expr::Var(..)    => todo_m5!("T-Var / T-Self"),
Expr::Block(..)  => todo_m5!("T-Block / T-Let / T-Seq"),
Expr::If(..)     => todo_m5!("T-If"),
// … and the rest, T-Lam and T-App among them

pub fn unify(&mut self, a: &Ty, b: &Ty, span: Span) -> Result<(), TyError> {
    todo_m5!(
        "unify (structural agreement; solves TyKind::Meta with an occurs check)"
    )
}

Where a rule needs two types to agree, the arm calls unify rather than comparing them with ==, since either may hold a variable that the comparison has to solve.

The provided driver walks the program’s declarations and calls check_expr on each body: a fn against its declared return type, in a context binding its parameters. After each declaration it asks that every type recorded for it be fully determined, which is where let xs = []; is rejected. Writing the arms and unify is Milestone M5.

Where errors are reported

The order in which a bidirectional checker visits a declaration decides where a mismatch is reported. In

fn f(c: Bool) -> Int = if c { 1 } else { "one" };

the body is checked against Int, the if passes the type on to its branches, and the error points at "one": expected Int, found String. With apply from above,

println(apply(|n| n ++ "!", 5));

is rejected at n, with expected String, found Int. gave n the type Int from apply’s parameter, and ++ then needed a String. A checker that synthesized the lambda’s type first would have found and reported the mismatch at the whole lambda, against apply’s parameter type.

The design space

Where the types go

Bridger’s checker answers yes or no and leaves its types in a side table. A checker can keep less: one that only validates computes each type, uses it to check the parent, and discards it, which is enough to accept or reject but gives later passes nothing to read. It can also keep more. It can write each type into the AST node it belongs to, so that a later pass reads a node’s type from the node, at the cost of an AST whose shape changes between the parser and the checker. Or it can elaborate the program into a separate typed representation, a new tree in which every node carries its type and constructs the types have resolved are made explicit. Compilers take the last route, and generate code from the typed representation; the types are discarded when the machine code is produced, which is why a compiled program carries no types at run time. Bridger’s evaluator needs nothing from the checker in this part, and the side table keeps the AST the evaluator walks the same before and after the checker arrives. Part IX is where the checker first hands the evaluator something it decided, a method chosen by type.

How much is inferred

Languages place the line between written and inferred types differently. Pascal and the C of the 1970s inferred nothing beyond the type of an operator’s result: every variable carried a declared type. ML infers the types of whole programs, top-level functions included. Bridger, like Rust and Pierce and Turner’s proposal, asks for types on top-level signatures and infers the types within a body. The written signatures are documentation a reader can rely on, and they keep each declaration’s inference small and its errors close to their cause; the cost is annotations that a whole-program inference would not need. The next chapter describes what Bridger’s choice leaves out.

Unification elsewhere

Unification is not specific to types. It solves equations between any terms built from constructors and variables, and Part VII uses it again, on the facts and rules of Bridger’s relations, where the variables stand for values instead of types.

Further reading

Bidirectional checking: David Christiansen’s “Bidirectional Typing Rules: A Tutorial” (2013) splits the typing rules of a small typed λ-calculus into checking and synthesis judgments and turns each rule into a case of a recursive function, which is the shape of check_expr. Jana Dunfield and Neel Krishnaswami’s survey “Bidirectional Typing” (2021) covers the design choices bidirectional systems make, for many type systems, and ends with historical notes on where the idea came from. Pierce and Turner’s “Local Type Inference” (2000) develops the two modes for a language with polymorphism and subtyping.

Unification: Robinson’s paper (1965) states the algorithm and proves that it finds a most general unifier, in the vocabulary of first-order logic. Pierce’s Types and Programming Languages (MIT Press, 2002) treats type inference by unification in chapter 22, where a program’s typing constraints are first collected and then solved.

Concept checks

The evaluator looks a variable up in ρ and the checker looks it up in Γ. Why can the checker build Γ before the program runs, and what in Bridger's design does it depend on?

follows the text: each let, parameter, and for adds a binding over a region of the program that is fixed by where it is written, and a variable’s binding is the nearest enclosing one. That is lexical scope, which Bridger has had since Part III. Under dynamic scope, a free variable in a function body would be bound by whichever caller was running when the body ran, and different callers could bind it at different types, so no context built from the function’s text would know its type. A checker for that discipline would have to record the dynamically bound names in the function’s type, as Haskell’s implicit parameters do.

Trace the inference variables in let xs = []; println(len(xs ++ [1]));. Why does the same program with len(xs) in place of len(xs ++ [1]) need an annotation?

[] has no element to type, so it gets for a fresh , and binds xs at . In xs ++ [1] the left operand synthesizes and the right ; list concatenation needs both at one type, so unification solves as Int. With len(xs), len accepts a list of any element type, so nothing constrains , and at the end of the declaration it is unsolved. Bridger requires every expression’s type to be determined, so the program is rejected and asks for let xs: [Int] = [];, or any element type.

Does let twice = |f| f(f(1)); trigger the occurs check? Work out the type of twice.

No. f gets . The inner call f(1) requires , which is solved. The outer call f(f(1)) checks its argument, of type , against f’s parameter type Int, solving as Int, and gives the body the type , which is Int. twice has type . f is applied to the result of applying f, but never to f itself, so no variable is ever asked to equal a type that contains it.

apply(|n| n ++ "!", 5) is reported at n. Where would a checker that always synthesized a lambda's type first report it, and which report is more useful?

Synthesizing first, n gets a fresh variable, n ++ "!" solves it as String, and the lambda synthesizes . Only then is it compared with apply’s parameter type, , and the mismatch is reported at the whole lambda, as two function types that differ. Checking first gives n the type Int from the context and reports the first place the body disagrees with it. Which is more useful depends on where the mistake is: if the lambda is wrong, pointing into it helps, and if the wrong function was passed, the whole-lambda report says so more directly.

Bridger's checker records types in a side table and the evaluator ignores them. Name something an evaluator could do with the types if it received them, and what Bridger would have to change to give them to it.

An evaluator that knew every addition’s operands were integers could skip the check that each operand is a Value::Int and add them directly; one that knew a call’s callee was a function could skip the check that it is a closure. More generally, a typed representation lets an implementation choose representations by type, storing an Int without a tag, which is what compilers do. To get there Bridger would have to pass the evaluator the TypeMap, or elaborate the program into a typed tree the evaluator walks instead of the AST, and the evaluator’s arms would change to rely on the types. As it stands, the evaluator keeps its checks, which never fire for a program the checker accepted, and one evaluator serves both the untyped milestones and the typed ones.