Skip to content
The Lumenary

Math laboratory

Type inference by unification: principal types as theorems

Infers principal simple types of untyped λ-terms by unification (Hindley-Milner without let): the I, K, S, B, C and W combinators, Church numerals and addition, and terms that cannot be typed, such as λx. x x and Y, which fail the occurs check. Each principal type is checked to be a classical tautology, as Curry-Howard predicts. Exact computations for these terms only.

Back to the labExact computation
Type inference by unification: principal types as theoremsPythonExact computationComputationally tested
"""Inferring simple types: which λ-terms are proofs, and of what?

Given an untyped λ-term, we look for a simple type for it by collecting
equations between type variables and solving them by unification, as in the
Hindley-Milner algorithm (without let). If a type exists, the result is the
most general (principal) one: every other type of the term is an instance.
Read through Curry-Howard, the principal type of a closed term is the most
general formula it proves, so each one is checked to be a tautology. Terms
built on self-application, such as ω = λx. x x and the Y combinator, fail
the occurs check: no type A can equal A → B. The results are exact
computations for the terms listed.
"""

import itertools
import re

def parse(text):
    """Parse a named term: ``\\x. M`` or ``λx. M``, application, brackets.
    Several binders may share one backslash: ``\\x y. M``."""
    tokens = re.findall(r"[\\λ.()]|[A-Za-z0-9_']+", text)
    pos = 0

    def peek():
        return tokens[pos] if pos < len(tokens) else None

    def take():
        nonlocal pos
        if pos >= len(tokens):
            raise ValueError(f"unexpected end of {text!r}")
        pos += 1
        return tokens[pos - 1]

    def term():
        if peek() in ("\\", "λ"):
            take()
            names = []
            while peek() != ".":
                names.append(take())
            take()
            body = term()
            for name in reversed(names):
                body = ("lam", name, body)
            return body
        result = None
        while peek() not in (None, ")"):
            arg = term() if peek() in ("\\", "λ") else atom()
            result = arg if result is None else ("app", result, arg)
        if result is None:
            raise ValueError(f"empty term in {text!r}")
        return result

    def atom():
        tok = take()
        if tok == "(":
            inner = term()
            if take() != ")":
                raise ValueError(f"missing ')' in {text!r}")
            return inner
        return ("var", tok)

    result = term()
    if pos != len(tokens):
        raise ValueError(f"trailing input in {text!r}")
    return result

class Untypable(Exception):
    """Raised when the equations have no solution."""

def resolve(t, sub):
    """Apply the substitution ``sub`` (type variable -> type) fully."""
    while t[0] == "tv" and t[1] in sub:
        t = sub[t[1]]
    if t[0] == "fn":
        return ("fn", resolve(t[1], sub), resolve(t[2], sub))
    return t

def occurs(v, t, sub):
    """Does the type variable ``v`` occur in ``t``?"""
    t = resolve(t, sub)
    if t[0] == "tv":
        return t[1] == v
    return occurs(v, t[1], sub) or occurs(v, t[2], sub)

def unify(a, b, sub):
    """Extend ``sub`` so that a and b become equal, or raise Untypable."""
    a, b = resolve(a, sub), resolve(b, sub)
    if a == b:
        return
    if a[0] == "tv":
        if occurs(a[1], b, sub):
            raise Untypable(f"occurs check: t{a[1]} = {show(b)}")
        sub[a[1]] = b
    elif b[0] == "tv":
        unify(b, a, sub)
    else:
        unify(a[1], b[1], sub)
        unify(a[2], b[2], sub)

def infer(term):
    """The principal type of a closed term, with variables renamed a, b, …
    in order of appearance; raises Untypable if there is none."""
    sub, counter = {}, itertools.count()

    def go(t, env):
        if t[0] == "var":
            if t[1] not in env:
                raise ValueError(f"free variable {t[1]}")
            return env[t[1]]
        if t[0] == "lam":
            a = ("tv", next(counter))
            return ("fn", a, go(t[2], {**env, t[1]: a}))
        f, x = go(t[1], env), go(t[2], env)
        r = ("tv", next(counter))
        unify(f, ("fn", x, r), sub)
        return r

    return rename(resolve(go(term, {}), sub))

def rename(t):
    """Rename type variables to 'a', 'b', … in order of first appearance."""
    names = {}

    def go(u):
        if u[0] == "tv":
            names.setdefault(u[1], "abcdefghijklmnopqrstuvwxyz"[len(names)])
            return ("tv", names[u[1]])
        return ("fn", go(u[1]), go(u[2]))

    return go(t)

def show(t):
    """Print a type with right-associative arrows."""
    if t[0] == "tv":
        return t[1] if isinstance(t[1], str) else f"t{t[1]}"
    left = show(t[1])
    if t[1][0] == "fn":
        left = f"({left})"
    return f"{left} → {show(t[2])}"

def tautology(t):
    """Is the type, read as a formula of implicational logic, valid?"""
    names = sorted({u[1] for u in walk(t) if u[0] == "tv"})

    def value(u, v):
        if u[0] == "tv":
            return v[u[1]]
        return (not value(u[1], v)) or value(u[2], v)

    return all(value(t, dict(zip(names, row)))
               for row in itertools.product([True, False], repeat=len(names)))

def walk(t):
    """All subtypes of a type."""
    yield t
    if t[0] == "fn":
        yield from walk(t[1])
        yield from walk(t[2])

EXAMPLES = [
    ("I", r"\x. x"),
    ("K", r"\x y. x"),
    ("S", r"\f g x. f x (g x)"),
    ("B (compose)", r"\f g x. f (g x)"),
    ("C (flip)", r"\f x y. f y x"),
    ("W", r"\f x. f x x"),
    ("K I", r"(\x y. x) (\z. z)"),
    ("Church 2", r"\f x. f (f x)"),
    ("plus", r"\m n f x. m f (n f x)"),
    ("λx. λk. k x", r"\x k. k x"),
    ("ω = λx. x x", r"\x. x x"),
    ("Y", r"\f. (\x. f (x x)) (\x. f (x x))"),
]

def main():
    print("Principal types (read as formulas: each should be a tautology)")
    for name, src in EXAMPLES:
        try:
            ty = infer(parse(src))
        except Untypable as err:
            print(f"  {name:<13} untypable ({err})")
            continue
        mark = "tautology" if tautology(ty) else "NOT a tautology"
        print(f"  {name:<13} {show(ty):<38} {mark}")
    print()
    print("Epistemic status: exact computations for these terms. Unification "
          "finds\nthe principal type whenever one exists (Hindley 1969, "
          "Milner 1978); that\nevery simply typed closed term proves a "
          "tautology is Curry-Howard plus\nsoundness, proved in the lesson. "
          "A tautology need not have a term:\nPeirce's law is the standard "
          "counterexample.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in