Parametric Polymorphism and System F
Learning Objectives
- Write the syntax and the two typing rules that System F adds to the simply typed lambda calculus: type abstraction and type application .
- Define polymorphic terms (identity, Church booleans and numerals) and compute their types by instantiation.
- Use parametricity to derive a "free theorem" from a type alone, and explain when a language breaks it.
- Contrast System F with Hindley-Milner type inference, and relate the theory to how Java, C#, Rust, and Haskell implement generics.
Context & Motivation
software-distributed/programming-languages introduced the simply typed lambda calculus, in which id must be written once per type, and then type inference by unification, which assigns id the type without annotations. This concept looks at the system underneath: System F, discovered independently by Girard (1972) and Reynolds (1974), the polymorphic lambda calculus. It is the smallest calculus that has real generics, and it is the core of Haskell's intermediate language, an ancestor of ML-family polymorphism, and a clean model for <T> in Java and C#.
System F matters for practitioners for two reasons. First, it explains what a generic signature promises. Because a function of type cannot look inside a value of unknown type, the signature alone nearly determines its behavior, a principle Wadler called "theorems for free". Second, it explains why generics are implemented so differently across languages (erasure, monomorphization, dictionaries, reification), since the type-theoretic story is the same and the runtime stories are not.
Core Theory
Syntax and rules
System F extends the simply typed lambda calculus with a type variable , a universally quantified type, and terms to introduce and eliminate it:
In T-TAbs, must not already be free in . The identity function is with type , and has type . Type application is checked by substitution and erased at run time; in a language with inference you rarely write it.
A distinctive feature of System F is that the quantified type may be instantiated at a quantified type itself (impredicativity). The term is well typed, and it has no typing in the simply typed calculus or in Hindley-Milner.
Encoding data
System F can encode data types with no built-in constructors. Church booleans have type , with and false returning . Church numerals have type , and the numeral for applies its first argument times to its second. Every well-typed System F term terminates (strong normalization), so the calculus cannot express general recursion and is not Turing complete. This is the same reason a total functional language and a logic can share a type system, an idea developed in the concept on dependent types.
Parametricity and free theorems
A function of type must treat uniformly, since it has no operation available on values of an unknown type except moving them around. Reynolds formalized this as parametricity, and Wadler showed it yields theorems from types alone:
- must return its argument (or fail to terminate in a language with general recursion).
- commutes with
map: for every . So can reorder, drop, or duplicate elements by position, but it cannot depend on their values. - is either "return the first" or "return the second".
Inference: Hindley-Milner versus full System F
Hindley-Milner (ML, Haskell 98) restricts System F so that quantifiers appear only at the outermost level of a type (prenex, rank-1 polymorphism) and polymorphism is introduced only by let. That restriction makes type inference decidable and efficient by unification, with no annotations. Full System F is much more expressive but its type inference is undecidable (Wells, 1994), so programs need explicit type annotations once they use higher-rank types. GHC's RankNTypes and Java's explicit generic method calls (Collections.<String>emptyList()) sit on either side of that trade-off, with local inference filling in the obvious cases.
How languages implement it
The type theory is the same; the runtime is a design choice:
| Strategy | Languages | What happens |
|---|---|---|
| Erasure | Java, Haskell, OCaml | One compiled body; type arguments are checked and then removed |
| Monomorphization | C++ templates, Rust | One specialized copy per instantiation: fast, larger binaries |
| Dictionary passing | Haskell type classes | Capabilities (e.g. Ord) are passed as extra arguments |
| Reification | C# / .NET | The runtime knows List<int> and List<string> as distinct types |
Java's erasure explains its restrictions: no new T[], no instanceof List<String>, and casts inserted by the compiler at use sites. Java also breaks pure parametricity, because every object has equals, hashCode, toString, and getClass, and reflection and null are always available.
Worked Examples
Example 1: typing the identity and applying it
Problem: Derive the type of and then of .
Derivation: Under the context the variable has type by lookup. T-ABS gives under context . T-TABS then abstracts the type variable: . For the application, T-TAPP instantiates with , substituting in the body type to get . T-APP with yields . In Java the same two steps are static <T> T id(T x) { return x; } followed by id(true), where the compiler infers the type argument.
Example 2: Church numerals
Problem: Check that has type , and compute with it.
Solution: Inside the body, , so and . Abstracting over , , and gives , which is . To use it as an ordinary number, instantiate and supply succ and 0: reduces to . Successor is , and addition is .
Example 3: a free theorem as a property test
Problem: A library method has the signature static <T> List<T> rotate(List<T> xs), and the claim is that it never examines the elements. What can be tested without knowing the implementation?
Solution: Parametricity predicts for every function . A property-based test can generate a random list and a random , and check that the two sides are equal. If the test fails, the method is looking at element values (perhaps through equals, hashCode, or instanceof), which its signature does not admit. Contrast static <T extends Comparable<? super T>> void sort(List<T> xs): the bound is an extra capability, and the free theorem weakens to "commutes with map g when preserves order".
Common Misconceptions & Pitfalls
- "Generics are templates." C++ templates are instantiated per use and checked after instantiation. Java and System F generics are checked once, in generic form, and the checking is what gives the guarantee.
- "In Java,
<T> T f(T x)must be the identity." Only in System F. Java letsfreturnnull, throw, callx.toString(), or inspectx.getClass(), because the language has universal operations on all objects. - "System F is just Haskell types." Haskell's source language uses a rank-1 fragment with inference, and uses System F internally (as a typed core language) after elaboration. Extensions such as
RankNTypeslet you write higher-rank types, with annotations. - "Erasure means Java's generics are unsound." Erasure is an implementation strategy. The checker is sound as long as there are no unchecked warnings; raw types and unchecked casts cause heap pollution and the
ClassCastExceptions that come with it.
Summary
System F adds type abstraction and application to the simply typed lambda calculus, giving types with the rules T-TAbs and T-TApp, enough to encode booleans and numbers and to type self-application. Every typed term terminates. Parametricity says polymorphic functions treat their type argument uniformly, which yields free theorems from signatures. Hindley-Milner restricts System F to make inference decidable, and full System F inference is undecidable. Java erases type arguments, C++ and Rust monomorphize, Haskell passes dictionaries, and C# reifies, all implementing the same theory. The next concept adds the other half of object-oriented generics: subtyping, and how it interacts with parametric polymorphism through variance.