Parametric Polymorphism

Suppose you write an `identity` function, and a `compose` function, and a `map` function over lists. Without polymorphism, you need one copy for integers, one for strings, one for user records. The same three lines of logic get duplicated endlessly across every type your program touches. This is not a hypothetical annoyance. It is the reason C had to invent macros and `void` casts. It is also the reason early statically typed languages felt bureaucratic compared to dynamically typed ones. Parametric polymorphism solves this by letting a single definition work uniformly over all types. The type checker still proves, at compile time, that no misuse can occur. The idea sits at the center of every modern statically typed functional language — ML, Haskell, OCaml, F# — and its descendants. Rust's generics, Java's and C#'s generics, and TypeScript's generic functions all descend from it. Together they power essentially all reusable library code written today. When you write `List<T>`, `Vec<T>`, or a `map : (a -> b) -> [a] -> [b]` function, you are invoking parametric polymorphism. The technique is inseparable from <a class="wiki-link" href="/wiki/cs-pl-type-inference-hindley-milner" data-track="wiki_article.body.wiki_link">Type Inference and Hindley-Milner</a>. Hindley-Milner type inference is precisely the algorithm that decides when a definition may be generalized to a polymorphic type. It also decides how that polymorphic type gets specialized at each use site. All of this happens without a single type annotation from the programmer. This article gives a precise account of universally quantified types. It covers the syntactic distinction between monotypes and polytypes. It also covers the two operations — generalization and instantiation — that let inference machinery move between them. We explain <strong>let-polymorphism</strong>, the specific and deliberately restricted form of polymorphism that Hindley-Milner supports. We explain exactly why full unrestricted polymorphism is not* decidable to infer. That means letting a lambda-bound variable also be polymorphic, and this motivates the restriction. Along the way we build the intuition that will later support Algebraic Data Types and Pattern Matching. There, polymorphic types combine with sum and product types. Together these give the type systems of ML-family languages their full expressive power. By the end, you should be able to read a Hindley-Milner type judgment involving `∀`. You should be able to explain why `let x = e1 in e2` can give `x` a more general type than a lambda parameter ever could. You should be able to trace the generalization/instantiation algorithm on a concrete program. Finally, you should be able to diagnose the classic "polymorphism doesn't play well with mutable references" bug. Every ML implementation has had to solve this bug.

Key takeaways
  • Parametric polymorphism lets one definition work for all types via universally quantified type schemes `∀α. τ`.
  • A monotype has no quantifiers; a type scheme (polytype) is a monotype wrapped in `∀` binders.
  • Generalization turns a monotype into a scheme by quantifying over free type variables not used elsewhere in the environment.
  • Instantiation turns a scheme back into a monotype by substituting fresh type variables for the bound ones — this happens at every use.
  • Let-polymorphism restricts generalization to `let`-bound names, not lambda parameters, keeping type inference decidable and efficient.
  • Interaction with mutable state (the "value restriction") is the classic subtlety that breaks naive polymorphism in ML-family languages.
The core insight

Parametric polymorphism lets a single term have type `∀α. τ`, meaning it type-checks correctly for every choice of α simultaneously, and Hindley-Milner achieves this cheaply by generalizing at `let` and instantiating fresh copies at each use.

The idea

A monomorphic type is a single, concrete type: `int`, `int -> bool`, `[string]`. A function with a monomorphic type works for exactly one instantiation of its type variables. Parametric polymorphism allows a term to have a type that is quantified over one or more type variables, written using the universal quantifier `∀`. The type

id:∀α. α→α\texttt{id} : \forall \alpha.\ \alpha \to \alpha

says that `id` behaves correctly for any type `α` substituted uniformly for every occurrence — the same code, `λx. x`, works whether `α` is `int`, `string`, or a list of records. This is "parametric" in the strict theoretical sense (owed to Strachey and formalized by Reynolds): the behavior of the function cannot depend on which type it was instantiated at, because the function has no way to inspect `α` at all. This is the crucial contrast with ad-hoc polymorphism (overloading, typeclasses), where different code genuinely runs for different types. Types come in two syntactic classes in the Hindley-Milner system. A type (monotype) τ is built from type variables and constructors: `τ ::= α | int | bool | τ1 -> τ2 | τ list | ...` — no quantifiers appear inside a monotype. A type scheme (polytype) σ is a monotype prefixed by zero or more universal quantifiers: `σ ::= τ | ∀α. σ`. Crucially, quantifiers only ever appear at the outermost level of a scheme — you cannot write `(∀α. α → α) → int` in this system. This restriction to prenex, rank-1 polymorphism is exactly what keeps type inference decidable and syntax-directed; allowing quantifiers to appear nested inside argument positions (rank-2 or higher polymorphism) requires programmer-supplied annotations and loses full inference, which is why Hindley-Milner deliberately stops at rank 1. Two operations connect monotypes and schemes. Instantiation takes a scheme `∀α1...αn. τ` and produces a monotype by substituting fresh, freshly-generated type variables `β1...βn` for `α1...αn` throughout `τ` — "fresh" meaning variables that appear nowhere else in the current inference, so they can later be unified independently at each use site. Generalization is the reverse: given a monotype `τ` derived under a typing environment `Γ`, it closes over the free type variables of `τ` that do not also appear free in `Γ`, producing `∀α1...αn. τ` where `{α1,...,αn} = ftv(τ) \ ftv(Γ)`. Generalization is only sound with respect to variables not constrained by the surrounding context — quantifying over a variable still needed elsewhere would let the checker "forget" a constraint and accept ill-typed programs.

How it works

Hindley-Milner's answer to "when do we generalize, and when do we instantiate?" is a simple syntactic rule tied to two constructs: variable lookup and `let`. Every time an identifier is looked up in the environment, its scheme is instantiated with fresh type variables — this is what allows the same `id` to be applied to an `int` in one place and a `bool` in another within a single expression. Every time a `let`-bound expression finishes type-checking, its inferred monotype is generalized with respect to the current environment before the bound name is added to scope for the body — this is what allows `let id = λx. x in (id 1, id true)` to type-check, whereas doing the analogous thing with a lambda parameter (`λid. (id 1, id true)`) does not type-check, because a lambda parameter is monomorphic: it gets one type, fixed once at the call site, for its entire scope. This asymmetry between `let` and `λ` is precisely let-polymorphism. It is a deliberate, principled restriction: full "let-like" polymorphism for every binder (including function parameters) would require quantifiers to appear in argument position of function types — rank-2 polymorphism — and type inference for that system is undecidable in general. Hindley-Milner buys decidability and an efficient (near-linear in practice) inference algorithm by confining generalization to `let`.

Algorithm W, the classical presentation of Hindley-Milner inference, encodes exactly this discipline. ``` // Algorithm W: infer(Γ, e) = (S, τ) // Γ : type environment mapping variables to type SCHEMES // e : expression // Returns a substitution S and a monotype τ such that S(Γ) ⊢ e : τ function infer(Γ, e): match e: case Var(x): if x not in dom(Γ): error "unbound variable" σ = Γ(x) // a scheme ∀α1..αn. τ0 τ = instantiate(σ) // fresh copy, see below return (id_substitution, τ) case Lit(c): return (id_substitution, typeOf(c)) // e.g. int, bool case Abs(x, e1): // λx. e1 — NO generalization here β = freshTypeVar() Γ' = Γ, x : β // β is a MONOTYPE scheme (no ∀) (S1, τ1) = infer(Γ', e1) return (S1, S1(β) -> τ1) case App(e1, e2): (S1, τ1) = infer(Γ, e1) (S2, τ2) = infer(S1(Γ), e2) β = freshTypeVar() S3 = unify(S2(τ1), τ2 -> β) return (S3 ∘ S2 ∘ S1, S3(β)) case Let(x, e1, e2): // let x = e1 in e2 — generalization HERE (S1, τ1) = infer(Γ, e1) σ1 = generalize(S1(Γ), τ1) // close over free vars not in Γ Γ' = S1(Γ), x : σ1 (S2, τ2) = infer(Γ', e2) return (S2 ∘ S1, τ2)

function generalize(Γ, τ): vars = freeTypeVars(τ) \ freeTypeVars(Γ) return ∀ vars . τ // if vars is empty, this is just τ (a monotype scheme) function instantiate(σ): // σ = ∀α1..αn. τ0 fresh = [freshTypeVar() for each αi] return substitute({α1 ↦ fresh1, ..., αn ↦ freshn}, τ0) ``` The unification step (`unify`) is the same Robinson unification used throughout Hindley-Milner and is described fully in Type Inference and Hindley-Milner; it is not repeated here except to note that it produces the most general unifier (MGU) — this is what guarantees the inferred type is the most general one possible, i.e., every other valid typing is an instance of it.

Figure
Generalization at `let`, instantiation at each use of `id`
let id = λx. x in (id 1, id true) infer(λx. x) x : β (monotype, no ∀) τ = β → β ↓ generalize (β not free in Γ) σ(id) = ∀β. β → β stored in Γ as a SCHEME use 1: id 1 instantiate ∀β.β→β fresh γ₁ ⇒ γ₁→γ₁, unify γ₁=int use 2: id true instantiate ∀β.β→β fresh γ₂ ⇒ γ₂→γ₂, unify γ₂=bool λ-parameter x (in the body of id): x : β is a MONOTYPE scheme — never generalized inside the λ body.

The diagram makes the two disjoint code paths explicit: the body of `λx. x` gets `x` a bare monotype `β` (Abs case — no generalization), while the whole function `id`, once its inference is complete, is generalized once at the `let` boundary into a scheme `∀β. β → β`. Each of the two call sites then independently instantiates that scheme with its own fresh variable, so `int` and `bool` can coexist without conflict — this is the entire mechanism of let-polymorphism in one picture.

Complexity & correctness

Time and space complexity. Instantiation and generalization themselves are cheap: instantiation is a linear-time substitution over the size of the scheme's monotype body, and generalization is a linear scan to compute `ftv(τ) \ ftv(Γ)` (using an efficient representation of `Γ`'s free variables, e.g. a level-based occurs check, this is close to constant amortized work per binding). The dominant cost in the whole algorithm remains unification, which is where the real complexity story lives. With naive substitution-based unification (as literally written above, applying `S1` to `Γ` eagerly), Algorithm W is exponential in the worst case on pathological inputs, because each `App` node can double the size of the type produced by composing substitutions eagerly (classical examples chain many nested lets that build up sharing which then gets copied). In practice — and in essentially every real compiler — this is avoided by implementing types as a union-find structure over mutable type variable cells rather than building explicit substitutions; unification becomes union-find's inverse-Ackermann-amortized operations, and the overall algorithm runs in near-linear time, `O(n α(n))`, for realistic programs, where `n` is program size. This is the standard justification: Hindley-Milner is efficient in the implementation that everyone actually uses, even though the textbook substitution-based presentation is worst-case exponential.

Correctness — soundness. Soundness means: if `Γ ⊢ e : σ` is derivable, then `e` cannot "go wrong" (get stuck) when evaluated, for any instantiation of the type variables in `σ`. The proof is the standard progress + preservation argument (syntactic type soundness), extended with one extra lemma specific to polymorphism:

  • Instantiation lemma: if `Γ ⊢ e : ∀α. τ` then for any monotype `τ'`, `Γ ⊢ e : τ[α ↦ τ']`. This says a polymorphic typing really does license every concrete substitution, which is the semantic content of `∀` — exactly Reynolds' parametricity property, that the term's behavior is uniform across all instantiations because it is the same syntactic term being type-checked, not a different one per type.
  • Generalization lemma (soundness of closing over unconstrained variables): if `α ∉ ftv(Γ)` and `Γ ⊢ e : τ`, then `Γ ⊢ e : ∀α. τ` is a valid strengthening, because no assumption elsewhere in `Γ` depends on what `α` is, so quantifying it introduces no unsoundness — any future instantiation is consistent with everything already assumed about `e`'s free variables.

The generalization lemma is exactly why the algorithm subtracts `ftv(Γ)`: quantifying over a type variable that Γ still depends on would be unsound, because a later use of some other variable sharing that type could force it to a specific type inconsistent with a different instantiation chosen at `x`'s use site. This is the textbook bug: forgetting to subtract `ftv(Γ)` (over-generalizing) breaks soundness; the correct algorithm never generalizes variables that appear in the environment.

Correctness — principal types. Algorithm W has the principal types property: whenever `e` is typable, W infers a type scheme `σ` such that every valid typing of `e` is a substitution instance of `σ`. This follows from unification producing a most general unifier at every step — if unification always returns the least-committal solution, and generalization quantifies over every variable that is free to vary, then no more specific inference could have thrown away typability. This is what makes Hindley-Milner inference genuinely a decision procedure rather than a heuristic: there is one canonical answer, and the algorithm always finds it or correctly reports "untypable."

The tradeoff — restriction to let-polymorphism. The soundness and decidability above depend on the rank-1 restriction: quantifiers appear only at the top of a scheme, never nested inside a function argument type. If you allowed a lambda parameter itself to have a polymorphic type — e.g., `λ(f : ∀α. α → α). (f 1, f true)` — this is higher-rank polymorphism, and inferring it (without an annotation on `f`) is undecidable in general. Systems that want this power (e.g. GHC Haskell with `RankNTypes`) require the programmer to write the polymorphic type annotation explicitly; inference is not attempted for it. This is the honest cost of let-polymorphism's decidability: full inference in exchange for a real, useful, but restricted form of genericity.

Worked example

Trace inference on: ``` let compose = λf. λg. λx. f (g x) in let double = compose (λn. n + n) (λn. n + n) in (double 3, compose (λb. not b) (λb. b)) ``` This example is deliberately non-symmetric: `compose` gets used once at `int -> int -> int` (via `double`) and once at `bool -> bool` directly, forcing two genuinely different instantiations of the same generalized scheme.

StepExpressionΓ (relevant entries)Inferred monotypeAction
1`λf.λg.λx. f (g x)``f:β1, g:β2, x:β3`unify forces `β1 = β4→β5`, `β2 = β3→β4`, result `(β4→β5)→(β3→β4)→β3→β5`Abs/App rules, no generalization (still inside `λ`s)
2`let compose = ...`Γ before: `{}``τ_compose = (β4→β5)→(β3→β4)→β3→β5`generalize: no free vars of τ appear in `Γ={}`, so σ = `∀β3 β4 β5. (β4→β5)→(β3→β4)→β3→β5`
3`compose (λn. n+n)` (first use)`compose : σ` (scheme above)instantiate σ with fresh `γ3,γ4,γ5`; unify `γ4→γ5` with `int→int` (from `n+n`) ⇒ `γ4=γ5=int`one fresh instantiation, independent of any other use
4apply again to `(λn. n+n)`—unify `γ3→γ4` = `int→int` ⇒ `γ3 = int`result type of `double` = `int→int`
5`let double = ...`Γ: `{compose:σ}``τ_double = int → int`generalize: `ftv(τ) \ ftv(Γ) = {}` (no free vars left — fully monomorphic here), σ_double = `int → int` (trivial scheme)
6`compose (λb. not b) (λb. b)` (second use)`compose : σ`instantiate σ again, with brand-new fresh `δ3,δ4,δ5` — completely independent of `γ3,γ4,γ5` from step 3unify `δ4→δ5 = bool→bool`, `δ3→δ4 = bool→bool` ⇒ `δ3=δ4=δ5=bool`
7`double 3``double : int→int`apply ⇒ `int`ordinary monomorphic application
8Final tuple—`(int, bool→bool)` composed appropriately — result type `(int, bool)` after fully applying second `compose` to nothing further, or `bool→bool` if left partially appliedwhole program type-checks

The crucial moment is step 2 → steps 3 and 6: `compose` is generalized exactly once, into `∀β3 β4 β5. ...`, and then that single scheme is instantiated twice, with completely disjoint fresh variables `γ` and `δ` each time. Because they are disjoint, unifying `γ4=int` in step 3 has no effect whatsoever on `δ4=bool` in step 6 — this disjointness is exactly what "fresh type variables at each instantiation" buys you, and it is why the same `compose` can be soundly used at incompatible concrete types within one program.

Common pitfalls & traps

PitfallWhy it happensConsequence
Generalizing inside a `λ` body (treating parameters as schemes)Confusing let-binding with lambda-bindingUnsoundly accepts e.g. `λid. (id 1, id true)` as if `id` were polymorphic — but at runtime there is one call site, one type; this is a soundness bug, not just a rejection
Forgetting to subtract `ftv(Γ)` during generalizationGeneralizing all free variables of `τ`, not just the "new" onesUnsound: quantifies over a variable that is still constrained by an enclosing binder, letting incompatible instantiations slip past the checker
Polymorphism + mutable references (`ref`) without a value restrictionA `let r = ref []` looks like an ordinary `let`, so its type gets generalized to `∀α. α list ref`Storing an `int` through one instantiation and reading a `string` through another — a genuine type-safety hole; ML languages patch this with the value restriction, generalizing only syntactic values, not arbitrary expressions
Reusing the same fresh variable across two instantiations of one schemeImplementation bug: forgetting to call the fresh-variable generator per instantiationThe two uses become spuriously linked, unifying types that should be independent, causing rejection of perfectly valid programs
Expecting inference to work for higher-rank (nested `∀`) polymorphism with no annotationsAssuming Hindley-Milron generalizes without limitType inference is undecidable there; the compiler must reject or require explicit annotations — not a bug, a fundamental limit

Pioneers & history

Origins · 1 minRobin Milner and how this was discovered
RM
Robin Milner
1934–2010 · University of Edinburgh, ML and LCF theorem proving

Robin Milner designed the ML language and, with Luis Damas, gave the type inference algorithm and its soundness/completeness proof that bears his name alongside J. Roger Hindley, who had independently discovered the same combinator-typing result years earlier in the context of the simply typed lambda calculus. Milner's 1978 paper "A Theory of Type Polymorphism in Programming" is the founding document of let-polymorphism as implemented in every ML-descended language since.

History
1958
— Curry and Feys study combinator type assignment, precursor to Hindley's system.
1969
— J. Roger Hindley proves the principal-type theorem for a combinatory logic type system.
1978
— Robin Milner publishes "A Theory of Type Polymorphism in Programming," introducing Algorithm W and let-polymorphism for ML.
1982
— Luis Damas and Milner formalize and prove soundness/completeness of what becomes known as the Hindley-Milner system.
1983
— John C. Reynolds formulates parametricity ("Types, Abstraction and Parametric Polymorphism"), giving the theoretical account of why generalized functions are uniform across types.
1990
s — Standard ML and Haskell adopt and refine the value restriction and extensions (typeclasses, higher-rank types) atop the Hindley-Milner core.

Where this matters

Every generic container and higher-order function in OCaml, Standard ML, F#, and Haskell relies on let-polymorphism exactly as described here — `List.map`, `Option.bind`, `Array.sort` are all polymorphic definitions generalized once at their `let`/`val` binding and instantiated fresh at each call site. Rust's generic functions and traits, while checked by a different (bidirectional, not full Algorithm W) system, inherit the same conceptual core: one definition, many monomorphic instantiations, with monomorphization at compile time producing specialized machine code per instantiation — the compile-time cost of parametricity paid explicitly rather than via runtime dispatch. TypeScript's generic functions (`function identity(x: T): T`) and Java's/C#'s generics likewise borrow the `∀`-quantified-scheme model, though with subtler variance and erasure tradeoffs specific to object-oriented type systems. Compiler infrastructure for functional languages (GHC, the OCaml/Flambda pipeline, F#'s FSharp.Compiler.Service) all implement some variant of Algorithm W or its constraint-based cousin, Algorithm M, precisely to support this feature efficiently at scale — and the value-restriction fix for mutable references is load-bearing in every real-world ML/Haskell standard library that exposes references or arrays.

Ask the tutor: Why exactly does allowing generalization inside a lambda body make type inference undecidable, and what would a rank-2 type annotation look like for a function that needs it?

What is the difference between parametric and ad-hoc polymorphism?

Parametric polymorphism means a single piece of code works uniformly for every type, without inspecting or branching on which type it was given — `id : ∀α. α → α` behaves identically no matter what `α` is, because the implementation literally cannot see it. Ad-hoc polymorphism (overloading, typeclasses, C++ templates with specialization) means genuinely different code may run depending on the type — `+` on integers and `+` on matrices do different work. Haskell's typeclasses sit between the two: they look parametric syntactically but dispatch on a runtime-passed dictionary, making them ad-hoc under the hood. Hindley-Milron's core `∀` is purely parametric; typeclass constraints layer ad-hoc dispatch on top without changing the underlying let-polymorphism machinery.

Why does generalization happen at `let` and not everywhere?

Because `let x = e1 in e2` is exactly the syntactic point where `e1`'s inferred type is fully known and about to be reused, potentially many times, in `e2`. Generalizing there lets each use of `x` in `e2` instantiate independently. Lambda parameters, by contrast, receive their type once, at the (single, syntactic) call site represented by β-reduction — there is no analogous "multiple independent uses of one type" structure to exploit, and trying to generalize them anyway requires quantifiers in argument position, which breaks decidable inference (rank-2+ polymorphism).

Is Hindley-Milner type inference really decidable, and how fast is it in practice?

Yes — Damas and Milner proved both soundness (well-typed programs don't go wrong) and completeness (if a type exists, the algorithm finds the principal one) for the rank-1 system. The naive substitution-based algorithm is worst-case exponential on adversarial nested-let inputs, but every practical implementation represents types via a union-find/mutable-reference graph, making unification amortized near-constant per operation and overall inference close to linear in program size for realistic code — this is why OCaml and Haskell compile large codebases with negligible type-checking overhead.

What breaks if you generalize inside a lambda body instead of restricting to let?

You get unsoundness, not just a more permissive type system. If `λid. (id 1, id true)` type-checked with `id : ∀α. α → α`, the type checker would be claiming the single function value passed in at the call site works at both `int → int` and `bool → bool` simultaneously — but there is only one concrete function object at runtime, chosen once by whoever calls this outer lambda. If the compiler then compiles specialized code assuming polymorphic dispatch that doesn't exist, or if the caller passes a genuinely monomorphic `int → int` function, the second use (`id true`) is a type error the checker wrongly approved.

How does the value restriction actually work in practice?

Concretely, ML/OCaml/Haskell define a syntactic class of "values": variables, lambda abstractions, and data constructors applied only to values — expressions guaranteed to have no side effects and to produce the same shape every time. Generalization is applied only when the right-hand side of a `let` is syntactically a value. `ref []`, being a function application (`ref` applied to `[]`), is not a syntactic value, so `let r = ref [] in ...` gets `r` a monomorphic type, fixed by however `r` is first used, rather than a dangerous `∀α. α list ref`. This is a conservative, purely syntactic check — some safe programs get rejected, but no unsafe ones are accepted.

How does parametric polymorphism relate to algebraic data types?

Type schemes and algebraic data types compose directly: a polymorphic data type like `'a list` or `'a option` is itself a scheme-parameterized type constructor, and constructors like `Some : ∀α. α → α option` are generalized exactly like polymorphic functions. Pattern matching over such polymorphic constructors (covered under Algebraic Data Types and Pattern Matching) is where the real expressive payoff shows up — a single `map` function can be written once, polymorphically, over any `'a list`, and the same generalization/instantiation machinery from Hindley-Milner is what makes the constructors, the function, and every call site all check out together.

Can parametric polymorphism be combined with subtyping, as in Java or TypeScript generics?

Yes, but the theory becomes considerably more intricate — this is sometimes called bounded or F-bounded polymorphism, and it introduces variance (covariance, contravariance, invariance) as a genuinely new concern absent from pure Hindley-Milner, since substituting a subtype for a type variable inside a generic container isn't always safe. Full principal-type inference typically breaks down once subtyping is added, which is why Java's and TypeScript's generics rely on explicit type annotations and local inference heuristics rather than a global Algorithm-W-style solver.

Before this

Where this leads