Type Inference by Solving Constraints
Type inference is used in functional programming languages to automatically deduce the type of expressions based on how the expression is used. Inference reduces the burden on the programmer who would otherwise have to write all the types manually. Anyone who uses an imperative statically typed programming language knows how quickly explicit type annotations can become burdensome. Type inference is slowly permeating non-functional languages such as C++ and C#, which widens the potential use of inference.
Programming language designers are very interested in how type inference is implemented. Unfortunately the vast majority of the content on type inference is contained in academic publications from the 1990s and early 2000s. The implementation details have not yet made their way to the sphere of general public knowledge, which hinders further adoption by programming language designers. This blog post will present an overview of solving type inference problems by solving a system of constraints. The solver will be capable of solving constraints in a Hindley-Milner type system. The algorithm presented here is significantly simpler than Algorithm W, which is the classic method of type inference. Most of the resources on the Internet revolve around Algorithm W, which is not ideal.
Understanding Kinds
Understanding kinds is a critical prerequisite to understand how types are represented by type expressions. Just as expressions have a type signature, a type expression has a kind signature. In its most basic form, a kind tells us how we can construct a type. We represent kinds by using asterisks *
and kind functions ->
. The asterisk is pronounced as “type”. Here is how we will represent kinds in the constraint solver:
So we have a representation of kinds, but what do they actually mean? The easiest way to understand kinds is by looking at a bunch of examples of types and type constructors. Monomorphic types such as Int
and Bool
have kind *
. Type constructors are handled differently. An example of a type constructor is []
(list), which has kind * -> *
. So list is a type constructor that takes in a type (which we represent with an asterisk), and returns another type. Therefore [Int]
has kind *
, since we applied the type Int
to the list type constructor []
, resulting in the type [Int]
. Types constructors can also in some situations be partially applied, just like value constructors. Kinds are right associative, so the kind *->*->*
is the same as *->(*->*)
The table below gives the kinds of various type expressions from Haskell:
Type Expression | Kind |
---|---|
Int |
* |
Bool |
* |
Maybe |
*->* |
Either |
*->*->* |
[] |
*->* |
-> |
*->*->* |
a->b |
* |
[Either Int Bool] |
* |
Either Int |
*->* |
(->) a |
*->* |
(,,,) |
*->*->*->*->* |
Mu where newtype Mu f = In { out :: f (Mu f) } |
(* -> *) -> * |
Note that partial application of type constructors have limitations. For example, the kind of [Either Int]
is nonsensical, and Haskell gives the following error: Expecting one more argument to ‘Either Int’. Expected a type, but ‘Either Int’ has kind ‘* -> *’
. The kind of the type constructors that we write are determined by the number of type variables on the left side of the type declaration. For example, the type data Foo a b c = ...
has kind *->*->*->*
since we have three type variables a
, b
, and c
. So kinds fundamentally represent the arity of type constructors.
Type Expressions
Programs are typically composed of expressions of values, constants, variables, operators, and functions. Analogously, we can have type expressions which live at the type level. First we will define type expressions in terms of type variables, type constructors, and type applications:
The definition above also includes the type Scheme
, which represents a type with a forall quantifier. Schemes are used when defining parametric polymorphic functions. Note that the constraint solver does not deal with Scheme
in any way. When we want to solve a constraint that involves a polymorphic function, we will first have to instantiate a scheme by substituting the quantified variables with fresh type variables. This will be discussed in more detail later.
We will now define some functions which will be helpful for creating specific types:
Defining some string conversion functions will be useful for debugging. These functions may introduce extraneous parenthesis, but they will get the job done for our purposes:
Although the constraint solver will not deal with type schemes directly, it is useful to define some functions that work with them.
tysubst
is a very important function. It is used both by the constraint solver and the type scheme functions.tysubst
takes intheta
, which maps type variables to type expressions. Every type variable in the domain oftheta
is replaced with a corresponding type expression. Type variables that are not present in the domain oftheta
remain untouched.freshtyvar
is used all the time when generating constraints. This generates a globally unique type variable.instantiate
converts a type scheme to a type expression by substituting the universally quantified type variables with the list of type expressions in theactuals
parameter.freevars
will tell us what type variables exist within a certain type expressiongeneralize
does the opposite ofinstantiate
by looking for free type variables in the type expression and then making them universally quantified. TheskipTyVar
parameter tellsgeneralize
which type variables it should ignore. This function is used when generalizing let bindings.
Constraints
We are now ready to define the types which will represent the constraints. The constraint type will be divided into three different value constructors:
Equal
which tells the solver that the two type expressions must be equal. Equal also includes anErrorMessage
, which is a lazily evaluated string. This ensures that the error messages are not actually created unless they are actually needed. In some of the compilers that I’ve written, the string includes information obtained from a disk read (to show the programmer where the error was in their source code), so making this lazy is very important for performance reasons. Including an error message here makes type errors that are very understandable.And
which conjoins two constraints that must both be satisfied.Trivial
is a constraint which is always satisfied.
conjoinConstraints
is a helper function which joins a list of constraints together by using the And
value constructor.
Solver
Now for the big finale! Given a system of constraints, we want to get a Map<TyVar, TyExpr>
(aka Subst
), which will tell us how we should map the unknown variables to concrete TyExpr
s.
- First we define some extension functions to F#’s
Map
module, which will be helpful in writing the solver. - The
varsubst
function takes in aSubst
and aTyVar
. If theTyVar
is in the domain of the substitution, we apply the substitution, otherwise we leave theTyVar
alone. compose
will join twoSubst
s together into a new map.compose
is more sophisticated than a simpleMap
merge. To start off, we union the two domains of the substitutions together. Then for everyTyVar
in the newdomain
, first applytheta1
by running it throughvarsubst
, then pass this result throughtheta2
via thetysubst
function we defined before.(|--->)
will create a new binding in an emptySubst
. If the variable being bounda
is exactly the same astau
there is no need to define a new substitution, so just returnidSubst
. Ifa
is in the free variables oftau
, then the constraint is unsolvable. An example where we can run into this is with the constrainta ~ [a]
. In all other cases, we should return a newSubst
, wherea
is mapped totau
.consubst
will update aConstraint
with the results of theSubst
.- The
solve
function will solve a system of constraints and return aSubst
as a solution.- In the case where the constraint is
Trivial
, then the system is solvable by the identity substitution. - In the case where the constraint is
And
, we firstsolve
the left hand side, then apply the resulting substitution to the right hand constraints. Then we solve the new right hand constraints by callingsolve
again. The final step is tocompose
the results together. - In the case where the constraint is
Equal
, we pattern match on the type pair(tau1, tau2)
.- In the case where one of the two sides is a type variable, then we try to bind one side to the other. If
(|--->)
returnsNone
, then the system cannot be solved, so we should raise aTypeError
. Otherwise, we return the substitution given by(|--->)
.
Notice that if both sides are type variables, then the left type variable ends up mapping to the right type variable. From the perspective of the constraint solver, this preference for mapping from left to right doesn’t matter much. In the larger picture, keeping this asymmetry in mind when generating constraints is important. For example, we should always prefer to place a user declared constraint on the right hand side so that the right side ends up in the codomain of theSubst
map instead of the domain. - In the case where the two sides were both type constructors, then we check if they are equal. If they are the same, then the identity substitution is returned. Otherwise, we raise a
TypeError
. - In the case where the two sides are type applications, then we make a new system of constraints by demanding that the left side of the type applications must be equal, and the right hand sides of the type applications must be equal. We then solve the new system of constraints via a recursive call.
- In all other cases, the system is unsolvable, so we raise a
TypeError
.
- In the case where one of the two sides is a type variable, then we try to bind one side to the other. If
- In the case where the constraint is
Now we can test our solver by writing some test cases:
On lines 1-3, we can see the result of the first call to solve. The constraint we were trying to solve was (t1, Number) ~ (Unit, t2)
, and the output was the Map
{t1 → Unit, t2 → Number}
, as expected. The second constraint was Unit ~ Number
, which wasn’t solvable. The solver correctly raised a TypeError
which notified us of the mistake in addition to showing the error message attached to the constraint.
Wrapping Up
Generating constraints is usually a straightforward process, and can be accomplished by using a recursive function. The constraint generation process will be the subject of my next blog post.
The constraint solver presented here is based on Norman Ramsey’s book Programming Languages: Build, Prove, and Compare. The book is still unpublished at the time of this post’s publication.
The code in this blog post is placed under the Unlicense license (which places the code presented here in the public domain).