Type Systems

Hindley-Milner

Infer the most general type — no annotations required

Hindley-Milner is the type inference algorithm at the heart of ML, Haskell, and OCaml. Algorithm W gathers constraints from an expression and unifies them. Infers polymorphic types without annotations. O(n) typical, EXPTIME worst case.

  • What it infersPrincipal (most general) type from no annotations
  • Main algorithmAlgorithm W (Damas-Milner 1982)
  • Core operationUnification (Robinson 1965) — solves type equations
  • Time complexityO(n) typical, EXPTIME worst case (Mairson 1990)
  • LanguagesSML, OCaml, Haskell, F#, Elm, PureScript, ReasonML
  • Killer featureLet-polymorphism — one definition, multiple type instances

Interactive visualization

Walk the expression λx. x + 1. Watch x get a fresh type variable α, the use of + force α = Int, and substitution propagate back to the final type Int → Int.

Open visualization fullscreen ↗

How Hindley-Milner works

You hand the compiler an expression like \x -> x + 1 with no type annotations. How does it figure out the type is Int -> Int?

Hindley-Milner walks the expression as an AST and does three things at each node:

  1. Fresh type variables. Every unknown gets a placeholder — α, β, γ. The parameter x starts with α.
  2. Constraints. The expression's structure imposes equality constraints. x + 1 forces α to equal the input type of +, which is Int.
  3. Unification. Solve the constraint set. The result is a substitution from type variables to types, which gets applied back to every type expression.

For \x -> x + 1:

// Step 1: fresh type variables
x : α     // parameter, fresh
+ : Int → Int → Int   // operator's known type
1 : Int   // literal

// Step 2: constraints from x + 1
//   x has type α
//   the left operand of + must be Int
//   ⇒ α = Int

// Step 3: unify
//   {α ↦ Int}

// Step 4: substitute back
//   x : Int
//   x + 1 : Int
//   \x -> x + 1 : Int → Int

The inferred type Int -> Int is the principal type — every other valid type for this expression is an instance of it. There's no instance of Int -> Int that's more general; conversely every more specific type (like Int -> Int itself) is reachable from the principal type by substitution. Damas and Milner proved in 1982 that this principal-type property always holds for HM-typeable expressions.

Algorithm W

The canonical inference algorithm. Recursive over the AST. Returns a substitution and an inferred type at each node.

// Algorithm W (Damas-Milner 1982)
// W(Γ, e) returns (substitution σ, type τ)

W(Γ, x):                        // variable
    let σ = id
    let τ = instantiate(Γ(x))   // fresh tvars for ∀ quantifiers
    return (σ, τ)

W(Γ, λx. e):                    // lambda abstraction
    let α = fresh()
    let (σ, τ) = W(Γ[x := α], e)
    return (σ, σ(α) → τ)

W(Γ, e1 e2):                    // application
    let (σ1, τ1) = W(Γ, e1)
    let (σ2, τ2) = W(σ1 Γ, e2)
    let α = fresh()
    let σ3 = unify(σ2 τ1, τ2 → α)
    return (σ3 ∘ σ2 ∘ σ1, σ3 α)

W(Γ, let x = e1 in e2):         // let-binding
    let (σ1, τ1) = W(Γ, e1)
    let σ1Γ = σ1 Γ
    let scheme = generalize(σ1Γ, τ1)   // close over free type vars
    let (σ2, τ2) = W(σ1Γ[x := scheme], e2)
    return (σ2 ∘ σ1, τ2)

The interesting case is let. After inferring the type of e1, the algorithm generalizes — quantifies free type variables. let id = \x -> x in ... gives id the polytype ∀α. α → α. At each subsequent use of id, the quantifier is instantiated with a fresh type variable. So id 5 uses α₁ → α₁ unified with Int → α₁ — yielding Int → Int; id "foo" uses α₂ → α₂ unified with String → α₂ — yielding String → String. Same definition, two different types. That's let-polymorphism.

Unification

The engine of HM. Given two types, find a substitution that makes them syntactically identical.

unify(τ1, τ2):
    if τ1 == τ2:
        return identity_substitution
    elif τ1 is a tvar α:
        if α occurs in τ2:
            error "occurs check failed — infinite type"
        return {α ↦ τ2}
    elif τ2 is a tvar β:
        return unify(τ2, τ1)
    elif τ1 = (A1 → B1) and τ2 = (A2 → B2):
        σ1 = unify(A1, A2)
        σ2 = unify(σ1 B1, σ1 B2)
        return σ2 ∘ σ1
    else:
        error "type mismatch"

The occurs check is critical. Without it, unifying α with α → β creates an infinite type — α = α → β = (α → β) → β = .... Most ML implementations forbid it; OCaml allows it as -rectypes for recursive types like 'a t = Node of 'a t list.

HM vs subtyping vs bidirectional

Hindley-MilnerSubtypingBidirectional
Inference scopeWhole expression — no annotationsLimited — usually needs annotationsLocal — propagates from annotations
PolymorphismLet-polymorphism (rank-1)Subtype polymorphismWhatever surface language has
Principal typesYes (Damas-Milner theorem)Often no — many incomparable typesNo — depends on annotation placement
Composes with overloadingPoorly — needs type classesYesYes
Composes with subtypingPoorlyNativeYes
Error messagesOften cryptic — "expected α, got Int"Clearer — type mismatches localizedClearer — context-sensitive
ComplexityO(n) typical, EXPTIME worstP or undecidable, depends on systemO(n)
Used bySML, OCaml, Haskell, F#, ElmJava, C#, Scala (mixed)TypeScript, Rust, Swift, Kotlin

HM gives you the strongest theoretical guarantee — principal types — and the most freedom from annotations. Bidirectional inference is more flexible but requires annotations at function boundaries. Real-world languages mix both: OCaml is HM with optional annotations; Rust is bidirectional but locally HM-like.

When HM matters for you

  • Writing Haskell, OCaml, F#, or Elm. When a function inferrer "can't figure out" the type, you're hitting HM's limits — typically polymorphic recursion or rank-2 types.
  • Designing a language. HM is the gold standard for inference. Even if you ultimately want subtyping or rank-N polymorphism, HM is the baseline to compare against.
  • Reading inference error messages. "Couldn't match expected type α with Int → Bool" is Algorithm W reporting a unification failure. Understanding what α is gives you the actionable hint.
  • Writing a type-driven analysis tool. Linters, refactorers, and search-by-type tools (Hoogle for Haskell) all use HM's principal-type property to find functions by their signatures.

Haskell example

-- Inferred without annotations
compose f g x = f (g x)
-- Inferred type: ∀a b c. (b -> c) -> (a -> b) -> a -> c

-- The classic identity function
identity x = x
-- Inferred type: ∀a. a -> a

-- A let-polymorphic use
example = let id = \x -> x
          in (id 5, id "hello", id True)
-- The single id is used at three different types:
--   id 5      :: Int
--   id "hello":: String
--   id True   :: Bool

-- An expression that won't infer (rank-2 polymorphism needed)
-- bad f = (f 5, f "hi")   -- ERROR: parameter f must be polymorphic
-- Haskell requires explicit forall to type this:
-- bad :: (forall a. a -> a) -> (Int, String)
-- bad f = (f 5, f "hi")    -- needs RankNTypes extension

Complexity and practical cost

  • Algorithm W typical case: O(n) for typical programs — the constraint graph is balanced and unification operates in O(1) per edge using union-find.
  • Pathological worst case: Mairson 1990 — a program of n lines whose inferred type has size 2^2^n. Real human-written programs never hit this; the construction nests let-bindings each doubling the prior type's size.
  • GHC's HM kernel: infers types for ~50,000 lines of Haskell per second on a modern laptop. Full GHC compile times are dominated by core-to-STG translation and code generation, not type inference.
  • OCaml's type inference: ~100,000 lines/second — faster than GHC because OCaml has a simpler type system (no type classes).
  • Memory: O(n) substitution table + O(depth) recursion. A 100,000-line program uses ~50 MB of inferrer state.
  • Practical EXPTIME blow-ups: Real-world reports are rare. The Haskell wiki has perhaps a dozen historical issues. Modern compilers add a "type size limit" check to abort before runaway types.

Common HM pitfalls

  • Polymorphic recursion. A recursive function whose recursive call uses itself at a different type — HM can't infer this without annotations. let rec f x = f (f x) with a different polymorphism per recursive call requires an explicit type signature.
  • Value restriction. SML and OCaml restrict generalization to "syntactic values" (lambdas, constants) to preserve type safety with mutable references. let r = ref [] in r := [1]; r := ["a"] would be unsound; the value restriction prevents r from being typed as ∀α. α list ref.
  • Rank-N polymorphism. HM only allows polymorphism at let-binding sites (rank-1). Functions taking polymorphic functions as arguments need rank-2 — Haskell's RankNTypes extension lifts this, but inference becomes undecidable.
  • Occurs check failures look weird. "Infinite type α = [α]" usually means you wrote a function trying to operate on its own result type — typically an accidental missing function-call or argument.
  • HM doesn't compose with overloading. Haskell's type classes solve this by adding constraint contexts (Num α => ...). Without type classes, HM forces you to pick concrete types where overloading would be natural.

Frequently asked questions

What is Hindley-Milner type inference?

A type inference algorithm that recovers the most general type of an expression without any annotations. Developed in the 1970s by Roger Hindley and Robin Milner independently. The algorithm — usually called Algorithm W — walks the expression's AST, generates fresh type variables, collects equality constraints from the expression's structure, and unifies them. The result is the "principal type" — any other valid type for the expression is an instance of it.

What's a type variable?

A placeholder for an as-yet-unknown type, written α, β, γ in math notation (a, b, c or t1, t2 in code). When the type inferrer first encounters a lambda parameter like x in \x -> x + 1, it assigns x a fresh type variable α. As inference proceeds and constraints accumulate (x + 1 requires α = Int because + takes Ints), the type variable gets substituted with the inferred concrete type.

What's unification?

The algorithm that solves type equations. Given two types, find a substitution that makes them syntactically identical. unify(α, Int) returns {α ↦ Int}. unify(Int → α, β → Bool) returns {α ↦ Bool, β ↦ Int}. unify(Int, Bool) fails — incompatible. Robinson's 1965 unification algorithm runs in O(n α(n)) using union-find on type variables.

What's let-polymorphism?

The mechanism that allows a let-bound function to be used at multiple types. Given let id = \x -> x in (id 5, id "foo"), id has the principal type α → α. At let-binding time, the algorithm "generalizes" — universally quantifies free type variables, producing the polytype ∀α. α → α. At each use site, it "instantiates" — replaces ∀α with a fresh type variable, then unifies with the use context. That's how id can be Int → Int at one use and String → String at another.

Why is HM EXPTIME in the worst case?

Mairson 1990 showed a pathological program of n lines whose inferred type has size O(2^2^n) — doubly exponential. The construction nests let-bindings each generalizing the prior type. Real programs never hit this — for human-written code, HM runs essentially in O(n). The pathological case appears only in adversarial constructions that programmers never write. Real-world languages cap recursion depth or restrict the generalization rule to avoid surprises.

What's the difference between HM and TypeScript's inference?

HM infers principal (most-general) types from no annotations. TypeScript is bidirectional — it propagates known type information forward (from annotations) and backward (from context), making local guesses. TypeScript supports features HM doesn't — subtyping, overloading, union/intersection types — but loses HM's strong principal-type guarantee. Most modern languages (Scala, Rust, Swift, Kotlin) use bidirectional inference because it composes better with subtyping and overloading.

Which languages use Hindley-Milner?

Standard ML (where it originated), OCaml, Haskell, F#, Elm, PureScript, ReasonML. All ML-family languages. Each adds its own extensions — Haskell layers type classes on top; OCaml adds row polymorphism for records; Elm restricts polymorphism for simpler error messages. The core HM kernel is essentially identical across these. Outside the ML family, MLton's HM implementation is the speed reference — it can infer types for hundreds of thousands of lines per second.