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 . 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:
- is the typing context: a map from variable names to type schemes.
- is the expression being typed.
- is a substitution: a finite map from type variables to types.
- is the inferred type (under substitution ).
- 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 applies first: .
Algorithm W, Case by Case
Variables: Look up in to find its type scheme . Instantiate 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.
Lambda: Assign the parameter a fresh type variable , extend the context, and infer the body type .
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.
Let: This is the crucial case. After inferring the type of the bound expression, generalize it — universally quantify the type variables that are free in but not in the surrounding context :
The resulting scheme is added to 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:
- (identical types need no substitution)
- if (occurs check)
- where and
- Otherwise: fail
The occurs check — requiring before binding — prevents constructing infinite types. Without it, unify(α, α → β) would bind , 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 , generalization produces the scheme . Each use of id instantiates to a fresh variable — for id 42, for id "hello" — so both type-check independently.
In the lambda form, id is a lambda-bound parameter, so it gets type (a fresh monotype, not a scheme). When typing id 42, we unify , fixing as a function from Int. Then id "hello" tries to unify , but is already , 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 succeeds with result , then is the principal type of under : every other valid type for is a substitution instance of .
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 (expression structure strictly decreases at each recursive call), and unification terminates because the occurs check prevents cycles.
Soundness: implies (the inferred type is actually valid).
Completeness: If (any valid typing), then succeeds and returns a type such that for some substitution (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: . Fresh , infer body: . Result: .
Step 2: Generalize. . Scheme: .
Step 3: . Instantiate id to . Type of 42 is Int. Unify with : gives . Result: .
Step 4: . Instantiate id to a fresh (independent of !). Unify with : gives . Result: .
Step 5: Final type: .
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.