R0KernelPath
module 169 / 262

Polymorphism & Type Inference

Polymorphism and type inference reveal how a language can be simultaneously flexible and safe — writing one function that works for all types while a compiler proves it correct without a single annotation.

~18h focused study10 concepts

Overview

One of the deepest tensions in programming language design is the tradeoff between flexibility and safety. Dynamic languages like Python let you write len(x) and trust that x will have a length at runtime — flexible, but errors surface only when the program runs. Static languages like C require you to declare every type explicitly — safe, but verbose and rigid. The question that consumed type theorists from the 1970s onward was: can we have both?

The answer turned out to be yes, and the mechanism is type inference with polymorphism. A language like Haskell or OCaml lets you write a single identity function id x = x without a single type annotation, and the compiler will figure out that it works for any type — integers, strings, functions, trees — while simultaneously guaranteeing that you never apply it in a way that would cause a type error. This feels like magic. It is not magic: it is a beautiful interplay of parametric polymorphism (the ability of a type to be universally quantified over types) and unification-based inference (an algorithm for solving type equations).

This module builds the story in layers. We start with the problem — why monomorphic type systems are limiting — then introduce System F, the theoretical foundation for parametric polymorphism. We then develop Hindley-Milner (HM), the practical subset of System F that admits complete type inference, and walk through the Algorithm W that ML compilers actually run. Along the way we meet unification (Robinson's algorithm), type schemes, let-polymorphism, and the subtle reasons why HM is decidable while full System F inference is not. By the end you will understand not just how Haskell infers types, but why the algorithm is correct and what its limits are.

The ideas here are central to modern programming languages. Every production ML system — OCaml, Haskell, Rust's trait inference, Swift's generics, even TypeScript's structural typing — traces its intellectual lineage to Hindley-Milner. Understanding HM at depth gives you a lens for reasoning about any type system you encounter.

why this matters for the kernel

Type inference is not a syntactic convenience — it is a fundamental result in mathematical logic and computability. The Curry-Howard correspondence tells us that types are propositions and programs are proofs; type inference is therefore automated theorem proving for the statements "this program is well-typed." The fact that it terminates in nearly-linear time for HM is remarkable and non-obvious.

In practice, polymorphism and type inference shape the ergonomics and expressiveness of every modern statically-typed language. Rust's borrow checker relies on type inference to make ownership annotations bearable. TypeScript's inference engine allows JavaScript developers to get safety without rewriting everything. GHC's Haskell extends HM with type classes, GADTs, and rank-N types, enabling library authors to encode complex invariants directly in types — making entire classes of bugs impossible to write. Understanding where HM works, where it breaks down (recursive types, rank-2 polymorphism, impredicative instantiation), and why, gives you the foundation to read and reason about modern type-system research and to make principled decisions when designing APIs or DSLs.

Algorithm W: Type Inference Derivation for 'let f = λx.x in f f'

rendering diagram…

Algorithm W traces through let f = λx.x in f f. The let binding generalizes λx.x into a scheme ∀a0. a0 → a0. Each use of f in the body is independently instantiated with fresh variables (a1, a2), allowing f to be applied to itself. Unification resolves the result type to a2 → a2, which is then generalized to the principal type ∀a. a → a.

The concepts

01Monomorphism and Its Discontents

In a monomorphic type system, every expression has exactly one concrete type. C is monomorphic: int add(int a, int b) works only for int. If you want to add floats, you write a separate float fadd(float a, float b). This is tedious and violates the DRY principle, but worse, it means the type system cannot express the idea that addition is the same operation at its core.

The simplest polymorphism demand arises from the identity function. Logically, id(x) = x is the same function regardless of what x is — it does nothing to its argument. Yet in a monomorphic system you need one id per type. This explosion of identical code for structurally identical operations is the core problem polymorphism solves.

Ad-hoc polymorphism (overloading) is one escape hatch: you write multiple definitions under one name and the compiler picks the right one by the argument type. C++ and Java do this. But overloaded functions can behave differently for different types — + on strings concatenates, on ints adds. This is not the same as saying a single implementation works uniformly.

Parametric polymorphism is the stronger, cleaner notion: a single implementation that works uniformly for all types because it never inspects the type of its argument. The identity function id : ∀α. α → α is the canonical example. The ∀α says 'for any type α you choose to substitute here, this function accepts a value of that type and returns one of the same type.' The implementation is oblivious to what α actually is.

-- Monomorphic C-style: one function per type
int    id_int(int x)    { return x; }
char*  id_str(char* x)  { return x; }
double id_dbl(double x) { return x; }

-- Haskell: one parametrically polymorphic function
id :: forall a. a -> a
id x = x

-- Works for any type -- the type variable 'a' is a placeholder
id 42        -- a ~ Int
id "hello"   -- a ~ String
id id        -- a ~ (forall b. b -> b)

02System F: The Type Theory of Parametric Polymorphism

System F (also called the second-order polymorphic lambda calculus) is the formal language that makes parametric polymorphism precise. It was discovered independently by Jean-Yves Girard (1972, in proof theory) and John Reynolds (1974, in computer science).

System F extends the simply-typed lambda calculus with two new forms:

  1. Type abstraction Λα. e — a term that abstracts over a type variable α
  2. Type application e [τ] — instantiating a type-abstract term with a concrete type τ

The typing rules are:

  • If e : τ and α does not appear free in the context, then Λα. e : ∀α. τ
  • If e : ∀α. τ, then e [σ] : τ[σ/α] (substituting σ for α in τ)

The identity function in System F is written:

id = Λα. λ(x:α). x

and has type ∀α. α → α. To use it on integers you write id [Int] 42, which first instantiates the type variable (getting λ(x:Int). x : Int → Int) and then applies it to 42.

Key property — parametricity (Reynolds's theorem): Any term of type ∀α. α → α in System F must be the identity function. You cannot write a different function at this type. This is because the implementation cannot inspect α — it has no operations to apply to a value of unknown type — so the only thing it can do with its argument is return it. This free theorem is enormously powerful: the type alone tells you what the function does.

The bad news about System F: Full type inference for System F is undecidable (Wells, 1994). You cannot always reconstruct the type of an arbitrary System F term without annotations. This is why real languages do not implement full System F directly — they use the decidable fragment called Hindley-Milner.

-- System F identity, written out explicitly (Haskell with RankNTypes)
{-# LANGUAGE RankNTypes #-}

-- The type forall a. a -> a is a first-class type in System F
id :: forall a. a -> a
id = \x -> x

-- A rank-2 function: takes a polymorphic function as argument
-- This requires going BEYOND Hindley-Milner (needs explicit annotation)
applyToIntAndBool :: (forall a. a -> a) -> (Int, Bool)
applyToIntAndBool f = (f 42, f True)

-- Without the annotation, HM cannot infer the rank-2 type

03The Hindley-Milner Type System

Hindley-Milner (HM) is a restriction of System F that sacrifices some expressiveness to gain complete type inference — the ability to reconstruct the unique principal type of any expression without any annotations. It was described by Roger Hindley (1969) and Robin Milner (1978), with Damas and Milner giving the formal completeness proof in 1982.

The key restriction is that type variables can only be universally quantified at the outermost level of a let binding, not inside function types. This is called prenex or rank-1 polymorphism. Concretely:

  • ✓ id : ∀α. α → α — polymorphic at top level
  • ✗ (∀α. α → α) → Int — polymorphic inside a function argument (rank-2)

The two syntactic layers in HM are:

  • Monotypes (τ): concrete types with no ∀ — Int, Bool, τ₁ → τ₂, [τ], etc., plus type variables α which act as unknowns to be solved
  • Type schemes (σ): ∀α₁...αₙ. τ — a monotype with some variables universally quantified

The crucial rule is let-polymorphism: when you write let f = e₁ in e₂, the compiler generalizes the type of e₁ by universally quantifying all free type variables, producing a scheme. Inside e₂, each use of f gets a fresh copy of the scheme with new type variables, so each use can be instantiated independently.

This is why let id = λx. x in (id 1, id True) typechecks: the first id gets α₁ → α₁ with α₁ ~ Int, the second gets α₂ → α₂ with α₂ ~ Bool, and they don't interfere. But (λid. (id 1, id True)) (λx. x) fails in HM — here id is a lambda-bound variable, not a let-bound one, so it gets a monomorphic type variable, and using it at both Int and Bool creates a contradiction.

-- let-polymorphism works:
let myId = \x -> x
in (myId 1, myId True)   -- OK: two independent instantiations

-- lambda-bound: does NOT get generalized in HM
-- The expression below fails to typecheck in strict HM:
-- (\myId -> (myId 1, myId True)) (\x -> x)
-- Error: cannot unify Int with Bool

-- In OCaml:
let id x = x in
let _ = id 1 in          (* id : int -> int *)
let _ = id true in       (* id : bool -> bool *)
()  (* These look at first like contradictions -- but
       each use of id freshly instantiates the scheme *)

-- The let/lambda distinction is fundamental to HM
let polymorphic_ok = fun () ->
  let f = fun x -> x in  (* f : 'a -> 'a, a type scheme *)
  (f 1, f true)          (* fine *)

(* monomorphic because lambda-bound, not let-bound *)
let monomorphic_fail = fun f -> (f 1, f true)
(* OCaml infers f : int -> int, then rejects f true *)

04Unification: Solving Type Equations

At the heart of type inference is unification — an algorithm for finding a substitution that makes two terms identical. Robinson's unification algorithm (1965) solves equations over first-order terms, and it is exactly what HM uses to solve type equations.

A substitution S is a mapping from type variables to types. Applying S to a type replaces each variable with its mapped type. The unification problem is: given two types τ₁ and τ₂, find a most general unifier (mgu) — a substitution S such that S(τ₁) = S(τ₂), and S is as general as possible (any other unifier is a specialization of S).

Robinson's algorithm (structural recursion):

unify(τ₁, τ₂):
  if τ₁ == τ₂: return empty substitution
  if τ₁ is a type variable α:
    occurs_check(α, τ₂)   -- see below
    return {α ↦ τ₂}
  if τ₂ is a type variable α:
    occurs_check(α, τ₁)
    return {α ↦ τ₁}
  if τ₁ = C τ₁₁...τ₁ₙ and τ₂ = C τ₂₁...τ₂ₙ (same constructor C, same arity):
    S = unify(τ₁₁, τ₂₁)
    for i = 2..n: S = compose(S, unify(S(τ₁ᵢ), S(τ₂ᵢ)))
    return S
  else: FAIL (types are incompatible)

The occurs check: Before substituting α ↦ τ, we verify that α does not appear free in τ. Without this check, unify(α, α → β) would produce α = α → β, an infinite type. With the check, we reject this — most HM implementations correctly report a type error here. (Prolog historically skips the occurs check for performance, which is unsound.)

Example: Unify (α → Bool) with (Int → β):

  • Both are → applied to two arguments
  • Unify α with Int: S₁ = {α ↦ Int}
  • Unify Bool with β: S₂ = {β ↦ Bool}
  • Result: {α ↦ Int, β ↦ Bool}
  • Substituted: (Int → Bool) on both sides ✓

Unification is complete: if a unifier exists, Robinson's algorithm finds the most general one. The mgu is unique up to renaming of variables.

# Robinson's unification in Python (simplified, no occurs check for brevity)
from dataclasses import dataclass
from typing import Optional

@dataclass(frozen=True)
class TVar:
    name: str

@dataclass(frozen=True)
class TApp:
    constructor: str
    args: tuple  # of Type

Type = TVar | TApp

def apply_subst(subst: dict, t: Type) -> Type:
    if isinstance(t, TVar):
        return apply_subst(subst, subst[t.name]) if t.name in subst else t
    return TApp(t.constructor, tuple(apply_subst(subst, a) for a in t.args))

def compose(s1: dict, s2: dict) -> dict:
    result = {k: apply_subst(s1, v) for k, v in s2.items()}
    result.update(s1)
    return result

def occurs(var_name: str, t: Type) -> bool:
    if isinstance(t, TVar): return t.name == var_name
    return any(occurs(var_name, a) for a in t.args)

def unify(t1: Type, t2: Type) -> dict:
    t1, t2 = apply_subst({}, t1), apply_subst({}, t2)
    if t1 == t2:
        return {}
    if isinstance(t1, TVar):
        if occurs(t1.name, t2): raise TypeError(f"Occurs check: {t1.name} in {t2}")
        return {t1.name: t2}
    if isinstance(t2, TVar):
        if occurs(t2.name, t1): raise TypeError(f"Occurs check: {t2.name} in {t1}")
        return {t2.name: t1}
    if isinstance(t1, TApp) and isinstance(t2, TApp):
        if t1.constructor != t2.constructor or len(t1.args) != len(t2.args):
            raise TypeError(f"Cannot unify {t1} with {t2}")
        subst = {}
        for a1, a2 in zip(t1.args, t2.args):
            subst = compose(unify(apply_subst(subst, a1),
                                  apply_subst(subst, a2)), subst)
        return subst
    raise TypeError("Unification failed")

# Example: unify (a -> Bool) with (Int -> b)
arrow = lambda x, y: TApp("->", (x, y))
alpha, beta = TVar("a"), TVar("b")
Int, Bool = TApp("Int", ()), TApp("Bool", ())
result = unify(arrow(alpha, Bool), arrow(Int, beta))
print(result)  # {'a': TApp('Int',...), 'b': TApp('Bool',...)}

05Algorithm W: The HM Inference Engine

Algorithm W is Damas and Milner's type inference algorithm for HM. Given a typing context Γ (mapping variable names to type schemes) and an expression e, it returns a substitution S and a type τ such that S(Γ) ⊢ e : τ.

The algorithm is defined by recursion on the expression structure:

Variables x: Look up the scheme ∀α₁...αₙ. τ for x in Γ. Create fresh type variables β₁...βₙ and return (id_substitution, τ[β₁/α₁,...,βₙ/αₙ]). This instantiation is where polymorphism is exploited — each use of a polymorphic function gets fresh variables.

Lambda λx. e: Create a fresh type variable α. Infer the type of e in Γ extended with x : α. Return the function type (possibly substituted α) → (inferred type of e).

Application e₁ e₂: Infer type τ₁ for e₁ and τ₂ for e₂. Create a fresh variable β. Unify τ₁ with (τ₂ → β). Return S(β) as the result type.

Let let x = e₁ in e₂: This is the critical case for polymorphism. Infer type τ₁ for e₁. Generalize τ₁ by quantifying all type variables that are free in τ₁ but not in Γ — this produces a type scheme σ. Infer the type of e₂ in Γ extended with x : σ. Return that type.

Generalization is what creates new polymorphic bindings. Variables that appear free in the context Γ cannot be generalized — they are constrained by an enclosing binding. Only the new free variables in τ₁ become ∀-quantified.

Algorithm W runs in nearly-linear time (almost linear in the expression size due to the union-find data structure used for efficient substitution). Its correctness has two parts:

  • Soundness: if W returns (S, τ), then S(Γ) ⊢ e : τ is a valid derivation
  • Completeness: if S(Γ) ⊢ e : τ for any S and τ, then W succeeds and returns the principal (most general) type
-- Tracing Algorithm W on: let f = \x -> x in f f

-- Step 1: infer type of  \x -> x  in empty context
--   Fresh variable: a0
--   Infer body x in {x : a0}  --> returns a0
--   Type of lambda: a0 -> a0
--   Substitution: empty

-- Step 2: generalize  a0 -> a0  in empty context
--   Free vars in type: {a0}
--   Free vars in context: {}
--   Quantify all: scheme = forall a0. a0 -> a0

-- Step 3: infer type of  f f  in {f : forall a0. a0 -> a0}
--   Infer f (function position):
--     instantiate scheme with fresh a1: type = a1 -> a1
--   Infer f (argument position):
--     instantiate scheme with fresh a2: type = a2 -> a2
--   Create fresh result variable a3
--   Unify (a1 -> a1) with ((a2 -> a2) -> a3)
--     Unify a1 with (a2 -> a2): S1 = {a1 |-> a2 -> a2}
--     Unify a1 with a3:         S2 = {a3 |-> a1} = {a3 |-> a2 -> a2}
--   Result type: S(a3) = a2 -> a2

-- Final principal type of the whole expression:
-- forall a. a -> a
-- (renaming a2 to a)

main :: IO ()
main = do
  let f = \x -> x
  print (f f 42)   -- = 42, type is Int

06Type Schemes, Generalization, and the Value Restriction

The distinction between monotypes and type schemes is not just formal bookkeeping — it has deep consequences for what programs typecheck.

Generalization happens only at let-bindings, and only for type variables that are not constrained by the outer context. The rule is:

gen(Γ, τ) = ∀(ftv(τ) − ftv(Γ)). τ

where ftv(τ) is the set of free type variables in τ. The intuition: variables already constrained by the surrounding context represent specific types chosen by the caller. Only truly unconstrained variables represent genuine polymorphism.

The value restriction is a crucial soundness constraint that arises when polymorphism interacts with mutable state. Consider this ML code:

let r = ref []  (* What is the type? *)

Naively, Algorithm W would give r : ∀α. α list ref — a polymorphic mutable cell. But then:

r := [1];        (* instantiate α ~ int *)
let x = !r in   (* instantiate α ~ string??? *)

This would be unsound: we store an int list, then read it as string list, allowing a segfault.

The value restriction (Wright, 1995) fixes this: generalization is only performed when the right-hand side of a let is a syntactic value (a lambda, a literal, a constructor applied to values — not a function application or a ref creation). ref [] is an application, not a value, so the type variable cannot be generalized. The compiler either infers a monomorphic int list ref based on usage, or reports an ambiguity error.

ML and OCaml both enforce the value restriction. Haskell sidesteps it differently — IO types and the type class system prevent the problematic pattern at a different level. Understanding the value restriction tells you why you sometimes see confusing "this type cannot be generalized" errors in OCaml.

-- OCaml: value restriction in action
let ok_poly = fun x -> x       (* value: a lambda *)
(* ok_poly : 'a -> 'a, properly polymorphic *)

let r = ref []  (* NOT a value: it is an application of 'ref' *)
(* r : '_weak1 list ref  -- weak type variable, not polymorphic *)

(* Fixing it: make it a function (a value) *)
let poly_ref () = ref []
(* poly_ref : unit -> 'a list ref  -- polymorphic! *)
(* Each CALL creates a new ref, so no aliasing problem *)

(* Why the value restriction is needed: unsound example
   (would typecheck without the restriction) *)
let evil () =
  let r = ref [] in              (* suppose: r : forall a. a list ref *)
  r := [1];                      (* use r : int list ref, store [1]   *)
  let x : string list = !r in   (* use r : string list ref, BOOM      *)
  List.hd x

07Parametricity and Free Theorems

One of the most beautiful consequences of parametric polymorphism is parametricity: the type of a polymorphic function places strong constraints on what the function can possibly do. Reynolds formalized this as the abstraction theorem (1983): every polymorphic function preserves all relations between types.

Practically, this gives rise to free theorems (Wadler, 1989): theorems you get for free just from the type, without looking at the implementation.

Example 1: Any function f : ∀α. [α] → [α] (takes a list of α, returns a list of α) must satisfy: for any function g : A → B, map g (f xs) = f (map g xs). That is, f commutes with map. Why? Because f cannot inspect the elements of the list (it does not know their type), so it can only rearrange, duplicate, or drop elements. The only way to rearrange and then map is the same as mapping and then rearranging.

Example 2: Any function f : ∀α. α → α must be the identity. (There is nothing else you can do with a value of unknown type.)

Example 3: Any function f : ∀α. α → [α] must return a list containing only copies of its input. It cannot conjure new α-values from nowhere.

Free theorems are not just theoretical curiosities. GHC uses them for optimization: if a function has a parametric type, the compiler can apply equational rewrites (like the map fusion law) knowing they are universally valid. The whole stream fusion optimization in Haskell's standard library relies on free theorems.

Parametricity also provides a security property: if a module exports a value of an abstract type T, and T is opaque to clients, then parametricity guarantees that clients cannot forge or inspect values of T — this is the type-theoretic foundation of data abstraction.

-- Free theorem for reverse : forall a. [a] -> [a]
-- For any f : a -> b:
--   map f (reverse xs) = reverse (map f xs)

-- We can VERIFY this holds without reading reverse's source:
import Data.List (reverse)

verify :: Eq b => (a -> b) -> [a] -> Bool
verify f xs = map f (reverse xs) == reverse (map f xs)

-- This is true for ALL f and xs by parametricity.

-- Another free theorem:
-- head . map f = f . head   (when list is non-empty)
-- Proof sketch: head can only look at position, not value.
-- map f changes values but not positions.
-- So head after map == map-then-head.

-- GHC uses this style of reasoning for rewrite rules:
{-# RULES "map/map" forall f g xs.
    map f (map g xs) = map (f . g) xs #-}
-- This rewrite is safe to apply because it is a free theorem!
-- No need to examine what f and g actually do.

08Type Inference in Practice: ML and Haskell

How do real compilers implement Hindley-Milner inference? The modern approach uses constraint generation followed by constraint solving, which is more amenable to good error messages than the original Algorithm W (which can report errors far from their source).

Two-phase approach:

  1. Constraint generation: Walk the AST and generate type equations τ₁ = τ₂ and instance constraints τ ≺ σ (τ is an instance of scheme σ). Each subexpression gets a fresh type variable; constraints express how subexpressions relate.
  2. Constraint solving: Run unification on the equations, propagating substitutions. Solve instance constraints by generalization.

OCaml implements a close variant of Algorithm W with mutable unification variables (refs to optional types). When you unify two types, you mutate the unification variables in place — this is far more efficient than building explicit substitution maps. GHC Haskell uses a similar approach with IORef-based unification variables internally.

Type error messages: One of the famous weaknesses of HM-based inference is error message quality. When unification fails, the error can be reported at the unification site, which may be far from where the programmer made the mistake. Modern systems use constraint origin tracking to report errors at the original source location.

Haskell extensions beyond HM:

  • Type classes: add qualified types (Show a) => a -> String. Inference is still mostly HM but now must also solve class constraints via dictionary passing.
  • GADTs (Generalised Algebraic Data Types): require bidirectional type-checking; pure inference is undecidable, so type annotations are required.
  • Rank-N types (forall under arrows): require explicit annotations and go beyond HM.
  • Type families and functional dependencies: add type-level computation; inference becomes complex and can fail to terminate.

Rust uses a bidirectional variant of HM with a second pass for trait resolution. The lifetime system is inferred separately via a region-inference algorithm that is also unification-based at its core.

-- Demonstrating HM inference in OCaml step by step
-- Consider: let compose f g x = f (g x)

-- Step 1: Assign fresh vars
--   f : a, g : b, x : c, result : d
-- Step 2: g x  --> generates constraint: b = c -> e (fresh e)
--   type of (g x): e
-- Step 3: f (g x) --> generates constraint: a = e -> d
--   type of f(g x): d
-- Step 4: lambda x.  : c -> d
-- Step 5: lambda g.  : b -> c -> d = (c->e) -> c -> d
-- Step 6: lambda f.  : a -> ... = (e->d) -> (c->e) -> c -> d
-- Step 7: generalize all free vars {a,b,c,d,e} -> renamed to a,b,c

-- OCaml confirms:
(* # let compose f g x = f (g x) ;; *)
(* val compose : ('a -> 'b) -> ('c -> 'a) -> 'c -> 'b = <fun> *)

(* Note: OCaml uses 'a not forall a -- the scheme is implicit *)

(* Haskell makes it explicit with ScopedTypeVariables: *)
{- 
compose :: forall a b c. (b -> c) -> (a -> b) -> a -> c
compose f g x = f (g x)
-}

09Limits of HM: Rank-N Types and Impredicativity

Hindley-Milner achieves complete inference only for rank-1 (prenex) polymorphism. Understanding its limits reveals why modern languages sometimes require annotations and what the cost of richer type systems is.

Rank of a type: The rank of a type is the maximum depth at which ∀ appears to the left of an arrow (in a contravariant position). Rank-1 types have ∀ only at the outermost level. Rank-2 types have ∀ at most one arrow deep. Full System F has no restriction.

Why does rank matter for inference? In rank-1 HM, when you apply a polymorphic function to an argument, you always instantiate a scheme from the context — you know statically which polymorphic binding is being used. In rank-2, the argument itself may be polymorphic, and the compiler needs to figure out how it should be polymorphic from context — this is underdetermined without annotations.

Impredicativity is the related issue where a type variable is instantiated with a polymorphic type (one containing ∀). HM allows instantiating ∀α. α → α with Int → Int, but not with ∀β. β → β. Impredicative instantiation makes unification much harder (it becomes undecidable in general).

Practical consequence: In Haskell, if you need to pass a polymorphic function as an argument (rank-2), you must write the type signature:

runOnBoth :: (forall a. a -> a) -> (Int, Bool)
runOnBoth f = (f 1, f True)  -- requires annotation

Without the annotation, GHC cannot infer this type. This is not a GHC limitation — it is a fundamental limit of any HM-style inference.

MLF and other extensions: Researchers have developed type systems like MLF (Botlan & Rémy, 2003) and HMF that extend HM to support impredicativity with a minimal annotation burden, but they are more complex and not widely deployed in production compilers.

{-# LANGUAGE RankNTypes #-}

-- Rank-1: OK in HM, no annotation needed
length' :: [a] -> Int        -- forall a. [a] -> Int
length' = length

-- Rank-2: needs explicit annotation
runOnBoth :: (forall a. a -> a) -> (Int, Bool)
runOnBoth f = (f 42, f True)   -- f used at two different types
-- Without the annotation, GHC says:
-- "Cannot instantiate unification variable with a type involving foralls"

-- Rank-2 in practice: ST monad's runST
-- runST :: (forall s. ST s a) -> a
-- The 'forall s' prevents the state thread from escaping!
-- This is a beautiful use of rank-2 types for safety.
import Control.Monad.ST
import Data.STRef

example :: Int
example = runST $ do
  ref <- newSTRef 0
  writeSTRef ref 42
  readSTRef ref
-- The 's' in 'ST s Int' is a phantom type that links the ref
-- to the runST scope -- it CANNOT escape because of rank-2.

10Row Polymorphism and Structural Typing

A natural extension of parametric polymorphism is row polymorphism, which applies polymorphism to the fields of record and variant types. This is the type-theoretic foundation of structural subtyping in languages like TypeScript.

The problem: In HM, record types are concrete — {x: Int, y: Int} and {x: Int, y: Int, z: Bool} are different types. A function getX : {x: Int} → Int cannot accept a three-field record. This forces languages toward either inheritance (Java/C++) or entirely structural typing.

Row polymorphism (Wand, 1987; Rémy, 1989) extends types with row variables that stand for an unknown set of additional fields:

getX : ∀ρ. {x : Int | ρ} → Int

The row variable ρ represents "any additional fields." The function accepts {x: Int, y: String}, {x: Int}, {x: Int, z: Bool, w: Char} — any record that has at least an x: Int field. Inference extends naturally: unification now must unify rows as well as simple types.

TypeScript uses structural typing without formal row polymorphism, but the intent is similar: an object with at least the required fields is assignable to a record type. TypeScript's inference engine handles this through subtyping rather than unification.

OCaml's polymorphic variants and object types implement a form of row polymorphism. An OCaml method call obj#method infers a row-polymorphic type for obj — it accepts any object that has at least the method method.

Elm uses row polymorphism for records, and it is one of the reasons Elm can give such excellent type error messages: the compiler knows exactly which fields are required vs. which are extra.

-- OCaml object types use row polymorphism
(* A function that works on any object with a 'name' method *)
let greet obj = "Hello, " ^ obj#name
(* Inferred: greet : < name : string; .. > -> string *)
(* The '..' is the row variable! Accepts any object with name field *)

(* TypeScript equivalent -- structural typing *)
type HasName = { name: string }  -- minimum requirement

function greet(obj: HasName): string {
  return `Hello, ${obj.name}`
}

// Works with any object that has at least 'name'
greet({ name: "Alice" })                       // exact match
greet({ name: "Bob", age: 30, job: "dev" })   // extra fields OK

// Haskell Records (without row poly -- more rigid)
data Named = Named { name :: String }
greetH :: Named -> String
greetH obj = "Hello, " ++ name obj
-- This ONLY works for Named, not any record with a name field

Key insights

  • ✦Parametric polymorphism is not just about code reuse — it is a logical statement: a function of type ∀α. τ works *uniformly* for all types, and this uniformity is precisely what makes free theorems possible.
  • ✦Hindley-Milner achieves complete inference by restricting to rank-1 (prenex) polymorphism. The let/lambda distinction is not a quirk — it is the exact mechanism that makes generalization sound.
  • ✦Unification solves type inference by treating types as first-order terms and type variables as unknowns. The most general unifier is unique, which is why each expression has a unique principal type.
  • ✦The occurs check in unification is not optional — without it, you can construct infinite types (cycles in the type graph), which breaks soundness.
  • ✦The value restriction (in ML) is the price of combining let-polymorphism with mutable state. It prevents a class of heap-corruption bugs by limiting generalization to syntactic values.
  • ✦Free theorems turn parametric types into theorems about program behavior — the type `∀α. [α] → [α]` tells you the function can only rearrange/drop elements, without reading the source.
  • ✦Full System F inference is undecidable (Wells, 1994). HM's restriction to prenex quantification is precisely what makes inference decidable and efficient.
  • ✦Rank-2 types in Haskell's `runST` are not just a technical curiosity — they enforce memory safety (preventing state-thread escape) through the type system itself.

Common pitfalls

  • ⚠Confusing ad-hoc polymorphism (overloading) with parametric polymorphism: an overloaded function can behave differently per type; a parametric function must behave uniformly. This distinction matters for what free theorems you can derive.
  • ⚠Forgetting the let/lambda distinction in HM: lambda-bound variables are monomorphic; only let-bound variables get their types generalized. `(\f -> (f 1, f True)) id` is a type error; `let f = id in (f 1, f True)` is fine.
  • ⚠Misunderstanding the occurs check: unifying α with α → β without an occurs check creates an infinite type. Most implementations reject this, but Prolog-style systems may silently accept it, causing subtle bugs.
  • ⚠Applying 'generalize all free type variables' naively: variables that appear in the outer context (Γ) are constrained and must NOT be generalized. Only variables free in τ but not in Γ become ∀-quantified.
  • ⚠Expecting HM to infer rank-2 types automatically: if you pass a polymorphic function as an argument, Haskell/OCaml requires an explicit type annotation. This is not a compiler limitation — it is fundamental to HM decidability.
  • ⚠Ignoring the value restriction in OCaml: `let r = ref []` produces a weak type variable `'_weak1`, not a polymorphic `'a`. Trying to use r at two different types will fail. Fix: wrap in a function.
  • ⚠Confusing instantiation (turning a scheme into a monotype by substituting fresh variables) with unification (solving equations between types). Instantiation always succeeds; unification can fail.

Practice

  1. 01
    medium

    Trace Algorithm W by hand on the expression `let compose = λf. λg. λx. f (g x) in compose`. What is the principal type of `compose`? Show each unification step, the fresh variables created, and the final generalization.

  2. 02
    hard

    Implement a minimal Hindley-Milner type inferencer in Python or OCaml that handles: variables, lambda abstraction, application, and let-bindings. Test it on at least three expressions including one that should fail (type mismatch) and one that demonstrates let-polymorphism.

  3. 03
    medium

    Prove (informally but rigorously) the free theorem for `map : (a → b) → [a] → [b]`: namely, that for any `f : a → b` and `g : b → c`, `map g (map f xs) = map (g . f) xs`. Your proof should rely only on the *type* of map, not its implementation, using parametricity.

  4. 04
    easy

    Explain why `(λf → (f 1, f True)) (λx → x)` is a type error in Haskell and OCaml, but `let f = λx → x in (f 1, f True)` typechecks. Then explain what *type system extension* would be needed to make the first expression typecheck, and what the required annotation would look like.

  5. 05
    medium

    In OCaml, explain why `let r = ref []` produces a weak type variable `'_weak1 list ref` instead of `'a list ref`. Write a version that IS properly polymorphic. Then construct a concrete example showing the unsound program that the value restriction prevents.

  6. 06
    easy

    Implement Robinson's unification algorithm in your preferred language with a full occurs check. Test it on: (a) unify `(a → b)` with `(Int → Bool)`, (b) unify `(a → a)` with `(Int → Bool)` (should fail), (c) unify `a` with `a → Int` (should fail the occurs check), (d) unify `(a → b → c)` with `(Int → Int → Bool)`.

Resources

📕Types and Programming Languages
bookpaidBenjamin C. Pierce, MIT Press (2002). The definitive textbook. Chapters 22–23 cover System F and HM in depth with full proofs.
📜Principal type-schemes for functional programs (Damas & Milner, 1982)
paperfree

The original paper proving completeness of Algorithm W. Short and readable.

📜Theorems for Free! (Wadler, 1989)
paperfree

The paper that introduced free theorems. Beautifully written and accessible.

📕Practical Foundations of Mathematics / Proofs and Types
bookfree

Robert Harper's Practical Foundations for Programming Languages (PFPL). Deep treatment of System F and parametricity. Free PDF available.

📜A Theory of Type Polymorphism in Programming (Milner, 1978)
paperfree

Milner's original paper introducing ML's type system and the let-polymorphism distinction.

✎Write You a Haskell — Type Inference (Stephen Diehl)
articlefree

An accessible, code-first walkthrough of implementing Algorithm W in Haskell.

📕Real World OCaml
bookfree

Practical guide to OCaml with good coverage of the value restriction and polymorphism in practice.

🎓CS 6110: Advanced Programming Languages (Cornell)
coursefree

Cornell's graduate PL course. Lecture notes on HM and System F are publicly available.

📜Abstraction and Parametricity (Reynolds, 1983)
paperpaidJohn C. Reynolds, 'Types, Abstraction and Parametric Polymorphism'. Information Processing 83, North-Holland.

The foundational paper on parametricity and the abstraction theorem. Often cited but hard to find free.

Checkpoints — you can now…

  • ✓You can explain the difference between parametric polymorphism and ad-hoc polymorphism (overloading), and give a concrete example where they produce different behavior.
  • ✓You can write the System F type and term for the identity function, and explain why type application is explicit in System F but inferred in HM.
  • ✓You can trace Algorithm W step by step on a simple expression (e.g., λf.λx. f x), showing fresh variable creation, unification steps, and the final generalization.
  • ✓You can implement Robinson's unification algorithm from scratch, including the occurs check, and explain why the occurs check is required for soundness.
  • ✓You can explain the let/lambda distinction in HM: why `let f = λx.x in (f 1, f True)` typechecks but `(λf. (f 1, f True)) (λx.x)` does not.
  • ✓You can describe the value restriction, construct an example of the unsound program it prevents, and explain how to work around it (eta-expansion).
  • ✓You can state at least two free theorems derived from parametric types (e.g., for `[a] → [a]` or `a → a`) and explain why they follow from parametricity without inspecting the implementation.
  • ✓You can explain why rank-2 types require explicit annotations in Haskell and connect this to the undecidability of full System F type inference.