Alonso's blog

Understanding Hindley–Milner

Disclaimer: Before starting, be warned this post is inspired by the video series Type Systems: Lambda calculus to Hindley-Milner by Adam Jones. I strongly recommend you watch this if you, like me, has been struggling to get into Hindley-Milner, and type theory in general.

Hindley-Milner is a type system for the lambda calculus. A type system is a set of rules for assigning types to expressions. They can verify that annotated types are correct, or infer the types. On the other hand, lambda calculus is a mathematical system created to reason about computation. Is like a really really small programming language.

The type system was originally described by J. Roger Hindley in 1969 for combinatory logic. Then, rediscovered by Robin Milner in 1978 for the ML programming language. Finally, Damas formalized the type system for lambda calculus in 1984.

The core value of Handley-Milner is to support generic programming while mostly not needing type annotations. With the Hindley-Milner system, the most general type for an expression can be inferred.

Most popular languages that use variations of this system include Haskell, OCaml, Rust, Swift, Elm and Gleam.

How this looks in practice?

The easiest way to implement a type system is to require type annotations, and then verify that incosistencies don't exist. The other easiest way to implement a type system it's to make the language sytanx directed. Meaning and integer literal always going to be an integer, unless stated otherwise.

This requires less type annotations, but some expressions will still need them to disambiguate. For example:

list = []
list.append(1)
list.append(2)
list.append(3)

Here, list needs a type annotation to know what type of list it is even though clearly its a list of integers.

In a Hindley-Milner type system, annotating the type of the list wouldn't be necessary. The type of list would be inferred based on how it is used. We can see that integers are appended after the declaration, so the list must be a list of integers.

This makes easy to write generic code and code that's easy to change, while maintaining static type safety. If you want to be explicit, you can always add type annotations later.

Formalization

Hindley-Milner consists of a set of rules about types in lambda calculus. Then, an algorithm can make use of these rules to infer types. That's the case for algorithm W and algorithm M.

Lambda calculus with let polymorphism (The language)

Its syntax can be described as:

e= x| e1 e2| λxe| let x=e1 in e2

An expression can be a variable/constant, an application, an anonymous function or a let expression. Some examples of expressions could be:

An application is just a function call.

On the other hand, the equivalent of a let expression in a modern programming language would be a function binding. This is what allows generic functions in a static type system.

(λ idid (odd (id 3)))(λ idid)

The above expression would work well in an untyped programming language. But even if it makes sense, we could not infer the types correctly in a static type system for the previous expression. For this we must use a let expression.

let id=λ idid in id (odd (id 3))

If we try to infer types for the function application, we first would find that the first argument of the identity function is an integer, and then a boolean, this would be a type error. On the other hand, with let expression, we found first that type of id is t.  tt. This means we can instantiate id separately in both function applications.

Types

τ= α| C τ1...τn

A type can be a type variable or a type function application.

C would be a constant type, like Int, Bool, List, , etc. It can have any number of parameters.

Type schemes

σ= τ|  α. σ

A type scheme may consist of a type, or a a type quantified with for all. When an expression has a type with type variables, the for all () allows you to instantiate the type variables every time the expression is used.

Type context

Is where you store what type different variables have. It's a list of variable to type assignments. Is denoted generally with Γ.

Typing rules (The type system)

A typing rule consists of a premise and a conclusion. If the premise is true, then its conclusion also must be true. They are denoted like:

Rule=PremiseConclusion

The typing rules for Hindley-Milner are:

x:σΓΓx:σ

Variable typing rule: If assignment x:σ is in Γ then in context Γ it follows that x has type σ.

Γe0:τaτb     Γe1:τaΓe0 e1:τb

Function application typing rule: If in context Γ it follows that expression e0 has type τaτb and expression e1 has type τa. Then, in context Γ it follows the expression e0 e1 has type τb.

Γ+x:τae:τbΓλxe:τaτb

Function abstraction typing rule: If it follows that expression e has type τb in the case we add x:τa to Γ. Then, it follows that in Γ the expression λxe has type τaτb.

Γe0:σ     Γ+x:σe1:τΓlet x=e0 in e1:τ

Let binding rule: If in Γ it follows that e0 has type σ, and it follows that if we add x:σ to typing context e1 has type τ. Then, it follows that the expression let x=e0 in e1 has type τ.

Algorithm W (An inference algorithm)

The following is the first algorithm proposed to infer types in Hindlye-Milner types. It is a recursive algorithm that takes as input a type context and an expression, and returns a substitution and a type. The type is what we care about.

W(Γ, x)= (identity, instantiate(Γ(x)))W(Γ, λxe)= let (S1,τb)=W(Γ+x: new τa,e)in (S1,S1(τaτb))W(Γ, e1 e2)= let(S1,τ1)=W(Γ,e1)(S2,τa)=W(Γ,e2)S3=unify(S2(τ1),τanew τb)in (S3 S2 S1,S3 τb)W(Γ, let x=e1 in e2)= let(S1,τ1)=W(Γ,e1)(S2,τ2)=W(S1Γ+x: generalize(S1Γ, τ1), e2)in (S2 S1,τ2)

The function instantiate takes a type scheme, and for each for all creates a new type variable and replaces it in the body of the type. The result is a type without for all quantifiers.

The function unify takes two types and returns a substitution that, when applied to both types, makes them equal.

A substitution is a list of mappings from type variables to other types.

The function generalize adds for all quantifiers to free type variables in a type. i.e. it adds a for all quantifier to all type variables in a type that are not currently quantified.

Algorithm M (Another inference algorithm)

M is also a recursive algorithm, but it takes as input a type context, a expression, and a type, and returns a substitution. If we want to infer the type of an expression e, we create a new type variable τ and call M(Γ,e,τ). Then we apply the resulting substitution to τ to know the type of e.

M(Γ, x, τ)= unify(τ, instantiate(Γ(x)))M(Γ, λxe, τ)= letS1=unify(τ, new τanew τb)S2=M(S1Γ+x: S1τa, e, S1τb)in S2S1M(Γ, e1 e2, τb)= letS1=M(Γ, e1, new τaτb)S2=M(S1Γ, e2, S1τa)in S2S1M(Γ, let x=e1 in e2, τ)= letS1=M(Γ, e1, new τ1)S2=M(S1Γ+x: generalize(S1Γ, S1τ1), e2, S1τ)in S2S1

Algorithm M is top-down instead of bottom-up. This means constraints are propagated from the top, and if we detect an error, we report it in a leaf.