Polymorphism, Subtyping, and Type Inference
Simple types classify terms but are deliberately modest. Real languages need reusable functions, records that can be passed where fewer fields are needed, and inference so programmers do not annotate every expression. TAPL develops these extensions in detail: ML-style reconstruction, System F, existential types, subtyping, bounded quantification, and higher-order systems [1]. Software Foundations reinforces the proof style, while program-analysis texts connect inference constraints with broader static analysis [2], [3].
The core tension is expressiveness versus decidability and usability. System F makes polymorphism explicit and powerful, but full inference is undecidable. Hindley-Milner gives principal types for a large ML-like fragment, but restricts where polymorphism is introduced. Subtyping models substitutability, but interacts with inference and variance in subtle ways.
Definitions
Parametric polymorphism allows one term to work uniformly for many types. In System F, types include variables and universal quantification:
Terms include type abstraction and type application:
The key rules are
Let-polymorphism generalizes types at let bindings. In ML notation:
is accepted because id is generalized to and instantiated separately at Int and Bool.
Hindley-Milner inference assigns principal type schemes without most annotations. It uses type variables, unification constraints, generalization, and instantiation. A type scheme is . The principal type is the most general type from which all other valid types can be obtained by substitution [4], [5].
Subtyping is a preorder , read "an may be used where a is expected." The subsumption rule is
For records, width subtyping allows extra fields:
Depth subtyping allows field types to vary covariantly for immutable records:
Permutation subtyping ignores field order. Function subtyping is contravariant in the argument and covariant in the result:
Bounded quantification in System F<: restricts type variables:
This expresses APIs such as "for any subtype of Comparable, return a result depending on that type."
Higher-rank polymorphism permits universal types to appear as function arguments. GADTs refine result types by constructors. Typeclasses and Rust traits provide ad-hoc polymorphism through dictionaries or monomorphized dispatch, complementing parametric polymorphism.
Key results
Parametricity. A closed System F term of type behaves like identity: it cannot inspect values of unknown type . Relational parametricity is deeper than typechecking; it gives "theorems for free" about polymorphic programs [6].
Principal types in Hindley-Milner. If an expression is typable in the HM fragment, Algorithm W computes a principal type scheme. Every other valid type is an instance of it. The proof relies on soundness and completeness of unification plus induction over expression structure [4], [5].
Unification produces most-general unifiers. Given equations between first-order type expressions, unification either fails or returns a substitution such that every other solution factors through . The occurs check prevents infinite types such as .
Subtyping is structural for records and arrows. Width, depth, and permutation rules capture substitutability for immutable records. Function argument contravariance is the rule students most often reverse: a function that accepts a broader input can stand in for one that only needs to accept a narrower input.
Value restriction. In impure ML-like languages, unrestricted let-generalization is unsound with mutable references. The value restriction generalizes only syntactic values or non-expansive expressions, preserving safety in the presence of effects.
Inference with subtyping is harder. Full combinations of HM-style inference and expressive subtyping can lose principal types or require constraint solving beyond simple unification. Practical languages choose restricted forms: local inference, bidirectional checking, trait constraints, or explicit annotations.
Elaboration explains implementation. A source language may present implicit polymorphism, typeclasses, or traits, but a compiler often elaborates this into a smaller explicitly typed core. An HM let binding can elaborate to type abstraction and type application in a System-F-like intermediate language. A Haskell typeclass call can elaborate to an extra dictionary argument containing the overloaded operations. Rust trait dispatch may monomorphize generic functions or use dynamic dispatch through trait objects. These elaborations are useful because they separate surface ergonomics from the metatheory of a smaller target calculus.
Bidirectional checking. Many modern typed languages use two mutually reinforcing judgments: checking, where an expression is checked against a known type, and synthesis, where an expression produces a type. Lambda abstractions are easy to check against an arrow type but hard to synthesize without annotations. Variables and applications often synthesize. Bidirectional typing supports higher-rank polymorphism and local inference without pretending that full inference is decidable. It also improves error messages because expected types flow inward through the syntax.
Parametricity has design consequences. A function of type cannot manufacture an arbitrary or inspect one, so it must return its argument or diverge in a language with nontermination. But adding effects, type reflection, unsafe casts, or ad-hoc overloading weakens that reasoning. This is why polymorphism is not just a notation for generics; it is a semantic promise about uniformity, and language features determine how strong that promise remains.
Subtyping algorithms need normalization. Declarative subtyping rules are written for clarity, not always for direct execution. Record permutation, transitivity, and bounded quantification can make naive proof search loop or branch wildly. Implementations often use algorithmic subtyping, which orients rules, removes admissible transitivity where possible, and normalizes record field order. TAPL separates declarative and algorithmic systems to prove that the checker is sound and complete for the intended subtyping relation [1]. This distinction mirrors the gap between a mathematical specification and a compiler implementation.
The same lesson applies to inference: a declarative typing rule may be elegant, while the implementation needs a syntax-directed algorithm, fresh-name discipline, and explicit failure modes.
Visual
| Feature | Typical notation | Strength | Cost |
|---|---|---|---|
| System F | explicit impredicative polymorphism | full inference undecidable | |
| Let-polymorphism | let id = ... | ergonomic reuse | polymorphism mostly at lets |
| HM inference | Algorithm W | principal types | limited subtyping and higher rank |
| Record subtyping | {a,b} <: {a} | flexible structural APIs | variance rules matter |
| Bounded quantification | generic code with subtype bounds | more complex metatheory | |
| Typeclasses/traits | constraints | ad-hoc overloaded operations | coherence and dispatch rules |
Worked example 1: Algorithm W for let id = fun x -> x in id id
Problem: infer a principal type for
let id = fun x -> x in id id
Step 1: infer the type of fun x -> x. Give x a fresh type variable . The body x has type , so
Step 2: generalize at the let. Since is not free in the environment,
Step 3: infer id id. Instantiate the function occurrence of id with a fresh type:
Instantiate the argument occurrence separately:
Step 4: application requires the function domain to match the argument type. Generate a fresh result type and constraint
Step 5: unify. The left side is an arrow, so match domains and codomains:
Thus .
Step 6: the expression has type
Generalizing the whole expression would yield if it were bound by another let. The checked result is that id id is again identity-like, not a type error, because let-polymorphism instantiated id twice.
Worked example 2: checking function subtyping
Problem: decide whether
assuming .
Step 1: use the function subtyping rule:
Step 2: identify
Step 3: check the argument side contravariantly:
which is true.
Step 4: check the result side covariantly:
also true.
Step 5: conclude
Intuition check: a context expecting a function from dogs to animals may safely receive a function that accepts any animal and returns a dog. It will only pass dogs, and receiving a dog is acceptable where an animal is expected.
Code
def occurs(name, ty):
tag = ty[0]
if tag == "var":
return ty[1] == name
if tag == "arrow":
return occurs(name, ty[1]) or occurs(name, ty[2])
return False
def apply_subst(subst, ty):
if ty[0] == "var" and ty[1] in subst:
return apply_subst(subst, subst[ty[1]])
if ty[0] == "arrow":
return ("arrow", apply_subst(subst, ty[1]), apply_subst(subst, ty[2]))
return ty
def unify(equations):
subst = {}
while equations:
left, right = equations.pop()
left, right = apply_subst(subst, left), apply_subst(subst, right)
if left == right:
continue
if left[0] == "var":
if occurs(left[1], right):
raise TypeError("occurs check failed")
subst[left[1]] = right
elif right[0] == "var":
equations.append((right, left))
elif left[0] == right[0] == "arrow":
equations.append((left[1], right[1]))
equations.append((left[2], right[2]))
else:
raise TypeError(f"cannot unify {left} with {right}")
return subst
Common pitfalls
- Confusing parametric polymorphism with overloading; parametric code is uniform across types.
- Forgetting to instantiate a polymorphic
letbinding at each use. - Generalizing lambda parameters in HM; generalization happens at
let, not every binder. - Skipping the occurs check and accidentally accepting infinite types.
- Assuming System F has full type inference; annotations or bidirectional restrictions are needed.
- Reversing function subtyping variance.
- Applying record depth subtyping to mutable fields without accounting for write safety.
- Treating subclassing and subtyping as identical; nominal languages may separate them.
- Expecting subtyping plus HM inference to always have principal types.
- Ignoring value restriction in languages with references.
- Assuming GADTs are just algebraic datatypes; constructors refine type equalities.
- Treating Rust traits as exactly Haskell typeclasses; coherence, ownership, and monomorphization affect behavior.
Connections
- Type Systems and Type Soundness supplies the preservation/progress obligations for these richer systems.
- Untyped and Typed Lambda Calculus is the base calculus extended here.
- Dependent Types and Proof Assistants continues from universal quantification to terms indexed by values.
- Dataflow Analysis and Abstract Interpretation shares constraint-solving themes with type inference.
- Compilers, Theory of Computation, Discrete Math, and Cryptography connect inference to implementation, decidability, partial orders, and verified APIs.
References
[1] B. C. Pierce, Types and Programming Languages. MIT Press, 2002.
[2] B. C. Pierce et al., Software Foundations, electronic textbook series.
[3] F. Nielson, H. R. Nielson, and C. Hankin, Principles of Program Analysis. Springer, 1999.
[4] R. Hindley, "The principal type-scheme of an object in combinatory logic," Transactions of the American Mathematical Society, 1969.
[5] R. Milner, "A theory of type polymorphism in programming," Journal of Computer and System Sciences, 1978.
[6] J. C. Reynolds, "Types, abstraction and parametric polymorphism," 1983.
[7] L. Cardelli and P. Wegner, "On understanding types, data abstraction, and polymorphism," ACM Computing Surveys, 1985.