Portfolio

CS & Mathematics · Cornell University · U.S. Top Secret Security Clearance

Type Inference Without Annotations: How Algorithm W Works

One of the most underappreciated achievements in programming language theory is complete type inference: the ability to assign a type to every expression in a program without any annotations from the programmer. You write let id = λx. x and the system figures out, on its own, that id has type α.  αα\forall\alpha.\; \alpha \to \alpha. Then it correctly checks id 42 : Int and id "hello" : String in the same program, using different instantiations of that one scheme.

This is what Hindley-Milner (HM) type inference gives you. The algorithm was independently discovered by Hindley (1969) for combinatory logic and by Milner (1978) for ML, then formalized by Damas and Milner (1982) with a proof that the algorithm finds the most general possible type — the principal type — for every typeable expression.

Here’s how it works.

The Core Judgment

The type inference judgment has the form:

W(Γ,e)=(S,τ,C)W(\Gamma, e) = (S, \tau, C)

  • Γ\Gamma is the typing context: a map from variable names to type schemes.
  • ee is the expression being typed.
  • SS is a substitution: a finite map [α1τ1,,αnτn][\alpha_1 \mapsto \tau_1, \ldots, \alpha_n \mapsto \tau_n] from type variables to types.
  • τ\tau is the inferred type (under substitution SS).
  • CC is a set of trait constraints accumulated during inference (for constrained polymorphism).

A substitution is applied to a type by replacing each free type variable with its assigned type. Composition S2S1S_2 \circ S_1 applies S1S_1 first: (S2S1)(τ)=S2(S1(τ))(S_2 \circ S_1)(\tau) = S_2(S_1(\tau)).

Algorithm W, Case by Case

Variables: Look up xx in Γ\Gamma to find its type scheme σ\sigma. Instantiate σ\sigma by replacing each universally quantified variable with a fresh type variable — this is what lets you use id at both Int and String in the same scope.

x:σΓτ=inst(σ)Γx:τ\frac{x : \sigma \in \Gamma \qquad \tau = \text{inst}(\sigma)}{\Gamma \vdash x : \tau}

Lambda: Assign the parameter a fresh type variable α\alpha, extend the context, and infer the body type τ2\tau_2.

W(Γ,λx.  e)(S,  S(α)τ2)W(\Gamma, \lambda x.\; e') \quad \Rightarrow \quad (S,\; S(\alpha) \to \tau_2)

Note: lambda parameters are not generalized — only let-bindings are. This is what makes the algorithm decidable.

Application: Infer the function type, then the argument type, then unify the function’s domain with the argument’s type.

W(Γ,e1)=(S1,τ1)W(S1(Γ),e2)=(S2,τ2)α=fresh()S3=unify(S2(τ1),  τ2α)W(Γ,e1  e2)=(S3S2S1,  S3(α))\frac{W(\Gamma, e_1) = (S_1, \tau_1) \qquad W(S_1(\Gamma), e_2) = (S_2, \tau_2) \qquad \alpha = \text{fresh}() \qquad S_3 = \text{unify}(S_2(\tau_1),\; \tau_2 \to \alpha)}{W(\Gamma, e_1\; e_2) = (S_3 \circ S_2 \circ S_1,\; S_3(\alpha))}

Let: This is the crucial case. After inferring the type τ1\tau_1 of the bound expression, generalize it — universally quantify the type variables that are free in τ1\tau_1 but not in the surrounding context Γ\Gamma:

gen(Γ,τ,C)=αˉ.  Cτwhereαˉ=FTV(τ)FTV(Γ)\text{gen}(\Gamma, \tau, C) = \forall\bar{\alpha}.\; C \Rightarrow \tau \qquad\text{where}\quad \bar{\alpha} = \text{FTV}(\tau) \setminus \text{FTV}(\Gamma)

The resulting scheme σ\sigma is added to Γ\Gamma for typing the body of the let.

Unification: The Heart of the Algorithm

Unification is the procedure that makes two types equal by finding the most general substitution (the most general unifier, or MGU) that, when applied to both types, makes them identical.

Robinson’s unification algorithm:

  • unify(τ,τ)=\text{unify}(\tau, \tau) = \emptyset (identical types need no substitution)
  • unify(α,τ)=[ατ]\text{unify}(\alpha, \tau) = [\alpha \mapsto \tau] if αFTV(τ)\alpha \notin \text{FTV}(\tau) (occurs check)
  • unify(τ1τ2,  τ1τ2)=S2S1\text{unify}(\tau_1 \to \tau_2,\; \tau_1' \to \tau_2') = S_2 \circ S_1 where S1=unify(τ1,τ1)S_1 = \text{unify}(\tau_1, \tau_1') and S2=unify(S1(τ2),S1(τ2))S_2 = \text{unify}(S_1(\tau_2), S_1(\tau_2'))
  • Otherwise: fail

The occurs check — requiring αFTV(τ)\alpha \notin \text{FTV}(\tau) before binding ατ\alpha \mapsto \tau — prevents constructing infinite types. Without it, unify(α, α → β) would bind α=αβ=(αβ)β=\alpha = \alpha \to \beta = (\alpha \to \beta) \to \beta = \ldots, producing an infinite type that cannot be represented finitely.

The Let-Polymorphism Distinction

Here’s the key distinction that makes HM tractable. Consider:

// Works:
let id = λx. x in (id 42, id "hello")

// Fails:
(λid. (id 42, id "hello")) (λx. x)

In the let form, after inferring id:αα\text{id} : \alpha \to \alpha, generalization produces the scheme α.  αα\forall\alpha.\; \alpha \to \alpha. Each use of id instantiates α\alpha to a fresh variable — β1\beta_1 for id 42, β2\beta_2 for id "hello" — so both type-check independently.

In the lambda form, id is a lambda-bound parameter, so it gets type γ\gamma (a fresh monotype, not a scheme). When typing id 42, we unify γ=Intδ1\gamma = \text{Int} \to \delta_1, fixing γ\gamma as a function from Int. Then id "hello" tries to unify γ=Stringδ2\gamma = \text{String} \to \delta_2, but γ\gamma is already Intδ1\text{Int} \to \delta_1, so unification fails.

Lambda parameters are intentionally monomorphic. Allowing polymorphic lambda parameters (as in System F) makes type inference undecidable — type-checking System F is equivalent to second-order unification, which does not terminate in general.

The Principal Types Theorem

The payoff of this careful construction is the Principal Types Theorem (Damas & Milner 1982):

If W(Γ,e)W(\Gamma, e) succeeds with result (S,τ,C)(S, \tau, C), then τ\tau is the principal type of ee under Γ\Gamma: every other valid type for ee is a substitution instance of τ\tau.

This means the algorithm doesn’t just find a type — it finds the most general one. And crucially, it always terminates: the algorithm’s correctness follows by structural induction on ee (expression structure strictly decreases at each recursive call), and unification terminates because the occurs check prevents cycles.

Soundness: W(Γ,e)=(S,τ)W(\Gamma, e) = (S, \tau) implies S(Γ)e:τS(\Gamma) \vdash e : \tau (the inferred type is actually valid).

Completeness: If Γe:τ\Gamma \vdash e : \tau' (any valid typing), then W(Γ,e)W(\Gamma, e) succeeds and returns a type τ\tau such that τ=S(τ)\tau' = S'(\tau) for some substitution SS' (the inferred type is at least as general).

A Traced Example

let id = λx. x in
let a = id 42 in
let b = id "hello" in
(a, b)

Step 1: W(,λx.  x)W(\emptyset, \lambda x.\; x). Fresh α\alpha, infer body: W({x:α},x)=(,α)W(\{x:\alpha\}, x) = (\emptyset, \alpha). Result: (,  αα)(\emptyset,\; \alpha \to \alpha).

Step 2: Generalize. FTV(αα)FTV()={α}\text{FTV}(\alpha \to \alpha) \setminus \text{FTV}(\emptyset) = \{\alpha\}. Scheme: α.  αα\forall\alpha.\; \alpha \to \alpha.

Step 3: W({id:α.  αα},  id  42)W(\{\text{id}:\forall\alpha.\;\alpha\to\alpha\},\; \text{id}\; 42). Instantiate id to β1β1\beta_1 \to \beta_1. Type of 42 is Int. Unify β1β1\beta_1 \to \beta_1 with Intβ2\text{Int} \to \beta_2: gives β1=Int\beta_1 = \text{Int}. Result: Int\text{Int}.

Step 4: W({,a:Int},  id  "hello")W(\{\ldots, a:\text{Int}\},\; \text{id}\; \text{"hello"}). Instantiate id to a fresh β3β3\beta_3 \to \beta_3 (independent of β1\beta_1!). Unify with Stringβ4\text{String} \to \beta_4: gives β3=String\beta_3 = \text{String}. Result: String\text{String}.

Step 5: Final type: (Int,  String)(\text{Int},\; \text{String}).

The critical step is that id is instantiated twice with different fresh variables. This is the mechanical consequence of let-polymorphism: each use of a let-bound name gets its own instantiation.

Why It Matters

HM is the foundation of ML, Haskell, OCaml, Elm, and many more languages. Its properties — complete inference, principal types, decidability — are not free: they follow from specific restrictions (no polymorphic lambda parameters, no impredicativity). Understanding the restrictions makes you understand the tradeoffs in languages that relax them: Haskell’s RankNTypes extension breaks inference for those annotations, System F is expressive but requires full annotations, and so on.

The algorithm also composes with extensions: refinement types add a second logic layer (SMT validity queries alongside unification), row polymorphism extends unification with row variables, and trait constraints add a third-stage resolution check. All three extensions preserve the core structure of Algorithm W while adding expressive power in orthogonal dimensions.