Blog. Understanding Local Type Inference [blog/lti-english][edit]
Blog. Understanding Local Type Inference [blog/lti-english][edit]
In the evolution of statically typed programming languages, the type inference mechanism has consistently played a pivotal role. It allows programmers to omit type annotations that can be derived from context, significantly reducing code redundancy and making programs more convenient to both read and write.
Against this backdrop, System $F_\leq$ stands as a landmark theoretical achievement. It elegantly unifies two concepts crucial to modern programming: subtyping—originating from object-oriented programming and greatly facilitating code reuse and abstraction—and impredicative polymorphism, the cornerstone of generic programming. However, a critical limitation hinders its practical adoption: its expressive power renders complete type inference an undecidable problem.
This article builds upon Pierce and Turner’s research in Local Type Inference while emphasizing engineering practicality. It proposes a fundamentally different approach: abandoning the pursuit of “completeness” in favor of exploring a simpler, more pragmatic partial type inference. The core innovation lies in introducing an additional simplifying principle—locality. By “locality,” we mean that any missing type annotation should be reconstructed solely using information from adjacent nodes in the syntax tree, without introducing long-range constraints (such as the global unification variables in Algorithm J).
For readers new to programming language theory, the mathematical symbols and inference rules scattered throughout the text may appear unfamiliar and intimidating. You can think of these rules as the fundamental “physical laws” of a programming language. In a highly rigorous manner, Most rules in this article are presented in the following form: In the premises and conclusions of the rules, you will repeatedly encounter a core judgment of the form $\Gamma \vdash e : T$. Let us break down each symbol: Putting it all together, the judgment $\Gamma \vdash e : T$ plainly means: After abandoning the pursuit of “complete type inference,” a crucial question emerges: To what extent must a “partial” type inference algorithm infer types to be considered “enough”? A highly pragmatic answer: The core mission of a good partial type inference algorithm is to eliminate those type annotations that are both pervasive and foolish. Here, “foolish” stands in contrast to “reasonable.” “Reasonable” annotations, such as type declarations for parameters in top-level function definitions, typically serve as valuable, compiler-verified documentation that aids code comprehension. “Foolish” annotations, conversely, add nothing but syntactic noise while providing almost no useful information. Imagine in a fully explicitly-typed language, no one would want to write or read those redundant Pierce’s study of hundreds of thousands of lines of ML code revealed three primary sources of “foolish annotations”: Based on these quantitative observations, we can outline the contours of an “enough” partial type inference algorithm: To achieve our goals, we need to design a set of efficient strategies for handling the three type inference scenarios described above. Both techniques adhere to the principle of locality, meaning all information required for inference is exchanged only between adjacent nodes in the syntax tree, without involving long-range dependencies or global unification variables. Before rigorously discussing the topic of type inference, we must first clearly define its inference target—an unambiguous, fully annotated internal language. This language serves as the “true” representation within the compiler and is the final form into which the external language—written by programmers and allowing omitted annotations—is translated. The internal language used in this article originates from the well-known calculus System $F_\leq$ proposed by Cardelli and Wegner, which incorporates subtyping and impredicative polymorphism. However, we extend it by adding the $\bot$ (Bottom) type. To ensure the existence of supremum and infimum, this algebraic structure’s completeness is fundamental for the concise and deterministic operation of the constraint-solving algorithm in subsequent chapters. Additionally, it can serve as the result type for expressions that never return (e.g., functions that throw exceptions). We first define the formal language as follows $$
\begin{array}{ll}
T ::= X & \text{type variable} \\
\quad~\mid~ \top & \text{maximal type} \\
\quad~\mid~ \bot & \text{minimal type} \\
\quad~\mid~ \forall \overline{X} . \overline{T} \rightarrow T & \text{function type} \\[1em]
e ::= x & \text{variable} \\
\quad~\mid~ \text{fun}[ \overline{X} ]\, ( \overline{x} : \overline{T} )\, e & \text{abstraction} \\
\quad~\mid~ e[ \overline{T} ]\, ( \overline{e} ) & \text{application} \\[1em]
\Gamma ::= \bullet & \text{empty context} \\
\quad~\mid~ \Gamma, x : T & \text{variable binding} \\
\quad~\mid~ \Gamma, X & \text{type variable binding}
\end{array}
$$ We can simply map the above formal language to the following MoonBit code, The subtyping relation, denoted as $S \lt: T$ (read as “$S$ is a subtype of $T$”), is the core of this formal system. It defines a “substitutability” between types: wherever a term of type $T$ is required, it is safe to substitute a term of type $S$. Unlike definitions in many theoretical works, this article deliberately adopts an algorithmic approach to present the subtyping relation. This means the definition includes only a minimal core set of rules directly usable for implementation of the decision procedure, while properties like transitivity—typically treated as axioms—become provable lemmas in this system. This style brings the definition closer to a specification for a type-checking algorithm: $$
\begin{array}{ll}
X \lt: X & \text{(S-Refl)} \\[1em]
T \lt: \top & \text{(S-Top)} \\[1em]
\bot \lt: T & \text{(S-Bot)} \\[1em]
\dfrac{
\overline{T} \lt: \overline{R} \quad S \le: U
}{
\forall \overline{X}. \overline{R} \rightarrow S \lt: \forall \overline{X}. \overline{T} \rightarrow U
}
& \text{(S-Fun)}
\end{array}
$$ where $\overline{T} \lt: \overline{R}$ holds if and only if $\text{len}(T) = \text{len}(S) \land \forall 1 \leq i \leq \text{len}(S). S_i \lt: T_i$. To support subsequent constraint-solving algorithms, the system must compute the least upper bound (join, denoted $\lor$) and greatest lower bound (meet, denoted $\land$) for any two types. Thanks to $\bot$ and $\top$, these operations are total functions in this system—bounds exist for every type pair. We define them as follows: Least Upper Bound $S \vee T$ Greatest Lower Bound $S \wedge T$ Simple structural induction proves these operations satisfy: Readers are encouraged to implement this in code; solutions can be viewed by expanding the snippet below. In MoonBit code, we implement $S \vee T$ as After defining the type and subtype relations, we can now present the typing rules for the kernel language. These rules define the typing judgment $\Gamma \vdash e : T$, meaning “in context $\Gamma$, term $e$ has type $T$.” Consistent with the definition of the subtype relation, these typing rules adopt an algorithmic presentation style and omit the subsumption rule commonly found in traditional type systems (where if $e$ has type $S$ and $S \lt: T$, then $e$ can also have type $T$). By omitting this rule, the system computes a unique, minimal type for every typable term, which the author calls its manifest type. This design choice does not alter the set of typable terms in the language but ensures that every term has exactly one type derivation path. This significantly enhances the system’s predictability. Variable $$
\frac{}{\Gamma \vdash x : \Gamma(x)} \quad (\text{Var})
$$ Abstraction $$
\frac{\Gamma, \overline{X}, \overline{x}:\overline{S} \vdash e : T}{\Gamma \vdash \mathbf{fun}[\overline{X}] (\overline{x}:\overline{S}) e : \forall \overline{X}.\overline{S} \to T} \quad (\text{Abs})
$$ To derive the type of $\mathbf{fun}[\overline{X}] (\overline{x}:\overline{S})$, we add bindings for type variables $\overline{X}$ and value variables $\overline{x}:\overline{S}$ to context $\Gamma$. We then derive type $T$ for the function body $e$ in this extended context. The entire function’s type is $\forall \overline{X}.\overline{S} \to T$. Application $$
\frac{\Gamma \vdash f : \forall \overline{X} . \overline{S} \to R \quad \Gamma \vdash \overline{e} \lt: [\overline{T}/\overline{X}]\overline{S}}{\Gamma \vdash f[\overline{T}] (\overline{e}) : [\overline{T}/\overline{X}]R} \quad (\text{App})
$$ Here, the notation $\Gamma \vdash \overline{e} \lt: [\overline{T}/\overline{X}]\overline{S}$ abbreviates $\Gamma \vdash \overline{e} \lt: \overline{U}$ followed by verifying $\overline{U} \lt: [\overline{T}/\overline{X}]\overline{S}$. The result type is obtained by substituting actual type arguments $\overline{T}$ into the function’s original return type $R$. A new notation $[\overline{T}/\overline{X}]R$ appears here,
representing the resulting type after substituting the type parameters $\overline{X}$ with the actual types $\overline{T}$.
This notation will appear frequently in subsequent code.
The following code defines the Bot Application $$
\frac{\Gamma \vdash f : \bot \quad \Gamma \vdash \overline{e} : \overline{S}}{\Gamma \vdash f[\overline{T}] (\overline{e}) : \bot} \quad (\text{App-Bot})
$$ This rule states that when a $\bot$-typed term is applied, the entire expression has type $\bot$ regardless of arguments—the most precise (minimal) result type possible. These rules collectively ensure a key property: Uniqueness of Manifest Types. If $\Gamma \vdash e : S$ and $\Gamma \vdash e : T$, then $S=T$. So far, we have defined the type rules for the core language,
but we are still far from our goal: the core language requires numerous annotations,
including explicitly providing type arguments during polymorphic instantiation.
Since this occurs frequently in code, it becomes the primary pain point we aim to address in this section. This is the goal of Local Type Argument Synthesis: allowing programmers to safely omit type arguments when calling polymorphic functions, writing $\text{id}(3)$ instead of $\text{id}[\mathbb{Z}](3)$. However, omitting type arguments introduces a core challenge: for a given application like $\text{id}(x)$ (where $x : \mathbb{Z}$ and $\mathbb{Z} \lt: \mathbb{R}$), there are often multiple valid type argument instantiations, such as $\text{id}[\mathbb{Z}](x)$ or $\text{id}[\mathbb{R}](x)$ here. We must establish a clear criterion for selection: choose the type argument that yields the most precise (i.e., smallest) result type for the entire application expression. In the $\text{id}(x)$ example, since the result type $\mathbb{Z}$ of $\text{id}[\mathbb{Z}](x)$ is a subtype of $\mathbb{R}$ (the result type of $\text{id}[\mathbb{R}](x)$), the former is clearly the superior and more informative choice. However, this locally optimal “best result type” strategy is not universally applicable. In some cases, a “best” solution may not exist. For example, consider a function $f$ with type $\forall X. () \to (X \to X)$. For the application $f()$, both $f[\mathbb{Z}]()$ and $f[\mathbb{R}]()$ are valid completions, yielding result types $\mathbb{Z} \to \mathbb{Z}$ and $\mathbb{R} \to \mathbb{R}$ respectively. These function types are incomparable under the subtype relation, so no unique minimal result type exists. In such scenarios, local synthesis fails. Recalling our earlier core language definition, we required application constructs to take the form $e[\overline{T}](\overline{e})$,
meaning we manually specified type arguments $\overline{T}$. Rule This introduces a new expression structure $$
\frac{\text{magic we don't know}}{\Gamma \vdash f (\overline{e}) : [\overline{T}/\overline{X}]R} \quad (\text{App-Magic})
$$ As humans, we can intuitively formulate rules or even design declarative rules that cannot be implemented as code,
precisely defining “what constitutes a correct, optimal type argument inference”: $$
\frac{
\begin{array}{l}
\Gamma \vdash f : \forall \overline{X}. \overline{T} \to R
\qquad \exists \overline{U}
\\
\Gamma \vdash \overline{e} : \overline{S}
\qquad
|\overline{X}| > 0
\qquad
\overline{S} \lt: [\overline{U}/\overline{X}]\overline{T}
\\
\text{forall} (\overline{V}). \overline{S} \lt: [\overline{V}/\overline{X}]\overline{T} \implies [\overline{U}/\overline{X}]R \lt: [\overline{V}/\overline{X}]R
\end{array}
}{\Gamma \vdash f(\overline{e}) : [\overline{U}/\overline{X}]R} \quad (\text{App-InfSpec})
$$ Here we use existential quantification $\exists \overline{U}$ and require $\overline{U}$ to satisfy multiple conditions.
For instance, $\overline{S} \lt: [\overline{U}/\overline{X}]\overline{T}$ is the validity constraint,
ensuring that the chosen type arguments $\overline{U}$ are legal.
Legality here means that after substituting $\overline{U}$ into the function’s formal parameter types $\overline{T}$,
the actual argument types $\overline{S}$ must be subtypes.
Crucially, the final rule $\text{forall} (\overline{V}). \overline{S} \lt: [\overline{V}/\overline{X}]\overline{T} \implies [\overline{U}/\overline{X}]R \lt: [\overline{V}/\overline{X}]R$
requires considering all possible type argument tuples $\overline{V}$.
This translates to searching through a potentially infinite space of $\overline{V}$,
making it a classic non-constructive description that cannot be implemented in a computer. Thus, our goal becomes clear: we need a truly executable algorithm whose results align with (App-InfSpec),
but without requiring non-constructive search or backtracking. This is precisely the role that constraint generation will play.
Its design motivation stems from an ingenious perspective shift on the problem itself. Rather than treating type parameter inference as a search problem of “finding and verifying the optimal solution” among infinite possibilities, Observing that our rules contain subtyping constraints, consider the general case $S \lt: T$, However, before systematically extracting these constraints, we must first address a preliminary yet critical challenge related to variable scoping. Suppose we want to generate constraints for variable $X$ such that type $\forall Y. () \to (Y\to Y)$ becomes a subtype of $\forall Y. () \to X$. According to the contravariant/covariant rules for function subtyping, this requires $Y \to Y \lt: X$. However, we cannot directly generate the constraint $\{ Y \to Y \lt: X \lt: \top\}$ because the type variable $Y$ is free in this constraint, yet it should be bound by $\forall Y$, resulting in a scope error. The correct approach is to find a supertype of $Y \to Y$ that contains no $Y$ and is as precise as possible, then use it to constrain $X$. In this case, that supertype is $\bot \to \top$. The variable elimination mechanism formally accomplishes this task of “finding the most precise bound”. These two relations are defined by a set of recursive rules, ensuring that for any type $S$ and variable set $V$, the resulting $T$ is unique and always computable (i.e., they are total functions). Promotion Rules ($S \Uparrow^V T$) Demotion Rules ($S \Downarrow^V T$) This can be implemented very straightforwardly in MoonBit: This carefully designed rule set guarantees the correctness and optimality of variable elimination, ensured by two key lemmas: Constraint generation is the core engine of the local type parameter synthesis algorithm. Constraint: In this system, each constraint takes the form $S_i \lt: X_i \lt: T_i$, specifying both a lower bound $S_i$ and an upper bound $T_i$ for a single unknown type variable $X_i$. Constraint Set: A constraint set $C$ is a finite mapping (implementable as a Hash Map in code) from a group of unknown variables $\overline{X}$ to their corresponding constraints. A key invariant is that the bounds ($S_i$, $T_i$) in any constraint must not contain any pending unknown variables (i.e., variables in $\overline{X}$) or any local variables to be eliminated (i.e., variables in $V$). The empty constraint set ($\emptyset$) represents the least restrictive constraint, equivalent to $\bot \lt: X_i \lt: \top$ for each $X_i$. Constraint set intersection ($\wedge$) is defined as the intersection of two constraint sets $C$ and $D$, obtained by merging constraints for the same variable. The new lower bound is the least upper bound (join, $\vee$) of the original lower bounds, while the new upper bound is the greatest lower bound (meet, $\wedge$) of the original upper bounds. The constraint generation process is formalized as a derivation relation $V \vdash_{\overline{X}} S \lt: T \Rightarrow C$, meaning: given a set of variables $V$ to avoid, the (weakest) constraint set $C$ on unknown variables $\overline{X}$ required for $S \lt: T$ to hold, where $C$ is the output of this relation. The algorithm is defined by recursive rules, with key rules as follows (note: we always assume $\overline{X} \cap V = \emptyset$): A crucial observation is that in any invocation $V \vdash_{\overline{X}} S \lt: T \Rightarrow C$, the unknown variables $\overline{X}$ appear only on one side of $S$ or $T$. This makes the entire process a matching-modulo-subtyping problem rather than a full unification problem, ensuring the algorithm’s simplicity and determinism. Through the preceding constraint generation step, we have successfully transformed a non-constructive optimal solution search problem into a concrete, tangible outcome: a constraint set $C$. This constraint set encapsulates all boundary conditions that the undetermined type parameters $\overline{X}$ must satisfy for the entire polymorphic application to be type-correct. For each unknown variable $X_i$, we obtain a valid interval of the form $S_i \lt: X_i \lt: T_i$. At this point, the first phase of the algorithm—“What do we know about the unknowns?”—has been successfully completed. However, our work is not yet finished. A constraint interval, such as $\mathbb{Z} \lt: X \lt: \mathbb{R}$, may itself permit multiple valid solutions (e.g., $\mathbb{Z}$ or $\mathbb{R}$). Ultimately, we must select a concrete type value for each $X_i$ to finalize the construction of the core language term. This leads us to the final and crucial step of the algorithm: By what criteria should we make the ultimate choice from each variable’s valid interval? The answer must return to our original intent—the optimality requirement declared in the App-InfSpec specification: Our choices must yield a unique, minimal result type for the entire application expression. Therefore, the algorithm’s last step is to design a selection strategy that leverages the constraint set $C$ we have diligently collected, combines it with the function’s original return type $R$, and computes a set of concrete type arguments that ultimately minimize $R$. This is the core task of this section: “Calculating Type Arguments.” To compute specific type parameters regarding $R$, there is another crucial operation: the change in polarity due to variable substitution. Currently, our consideration of polarity focuses primarily on function types, To minimize the return type $R$, our selection strategy becomes evident: The above strategy is formalized as an algorithm for computing the minimal substitution $\sigma_{CR}$. Given a satisfiable constraint set $C$ and return type $R$: For each constraint $S \lt: X_i \lt: T$ in $C$: When $\sigma_{CR}$ is undefined, the algorithm fails, precisely corresponding to the scenario in At this point, we have fully defined a completely executable algorithm consisting of three steps: “Variable Elimination,” “Constraint Generation,” and “Parameter Calculation.” But how do we ensure that the behavior of this concrete algorithm is entirely consistent with the non-constructive, declarative The proof of their equivalence is distilled into a core proposition asserting: Proof Sketch: Part 1 (Correctness of the Algorithm):
To prove that $\sigma_{CR}$ is optimal, we take an arbitrary other valid substitution $\sigma'$ satisfying the constraints and need to prove $\sigma_{CR}(R) \lt: \sigma'(R)$.
Consider constructing a “chain of substitutions” from $\sigma_{CR}$ to $\sigma'$, where each step changes only one variable’s value. For example, $\sigma_0 = \sigma_{CR}$, $\sigma_1 = \sigma_0[X_1 \mapsto \sigma'(X_1)]$, …, $\sigma_n = \sigma'$.
Next, we prove that at each step along this path, the resulting type is monotonically non-decreasing, i.e., $\sigma_{i-1}(R) \lt: \sigma_i(R)$. The proof of this step relies directly on the aforementioned polarity definitions. For instance, if $R$ is covariant in $X_i$, our algorithm selected the lower bound $S$, and $\sigma'(X_i)$ must be greater than or equal to $S$. Therefore, by the definition of covariance, the resulting type must “increase.” Similarly, the assertion holds in other cases.
Ultimately, by transitivity, we conclude $\sigma_{CR}(R) \lt: \sigma'(R)$, proving the optimality of $\sigma_{CR}$. Part 2 (Completeness of Algorithm Failure):
When the algorithm fails, it must be because the constraint interval $[S, T]$ for some invariant variable $X_i$ has $S \neq T$.
We use proof by contradiction: assume that an optimal solution $\sigma$ still exists under these circumstances. Since $S \neq T$, we can always find another valid substitution $\sigma'$ such that $\sigma(X_i)$ differs from $\sigma'(X_i)$. However, because $R$ is invariant in $X_i$, $\sigma(R)$ and $\sigma'(R)$ become incomparable. This contradicts the assumption that “$\sigma$ is the optimal solution (i.e., it is smaller than all other solutions)”.
Therefore, in this case, the optimal solution necessarily does not exist. This core proposition proves the equivalence between our designed concrete algorithm and the $$
\frac{
\begin{array}{ccc}
\Gamma \vdash f : \forall \overline{X}.\overline{T} \to R & \Gamma \vdash \overline{e} : \overline{S} & |\overline{X}| > 0 \\
\emptyset \vdash_X \overline{S} \lt: \overline{T} \Rightarrow \overline{D} & C = \bigwedge \overline{D} & \sigma = \sigma_{CR}
\end{array}
}{
\Gamma \vdash f(\overline{e}) : \sigma R
}
\quad (\text{App-InfAlg})
$$ At this point, we have thoroughly dissected the first key focus of this article: local type parameter synthesis. Through a logically rigorous and fully executable algorithm comprising “variable elimination,” “constraint generation,” and “parameter calculation,” it perfectly resolves the cumbersome type parameter annotation issues caused by fine-grained multipropositions, achieving the first of the three design principles stated in the introduction. However, our toolbox remains incomplete. Revisiting the three design principles proposed in the introduction based on ML code analysis, two critical challenges remain unresolved: The local type parameter synthesis mechanism, with its inherently bottom-up information flow, derives an optimal result type based on the existing types of functions and parameters. This approach falls short for the two problems above because expressions like We consider a powerful local inference technique conceptually complementary to the former. Instead of relying solely on bottom-up information synthesis, it introduces a top-down information flow, allowing the context surrounding an expression to guide its internal type checking. This is Bidirectional Type Checking. Though its core idea has long been a “folk consensus” in the programming language community and has been applied in some attribute grammar-based ML compilers, this article rigorously axiomatizes it within a formal system combining subtyping and impredicative polymorphism. We present it as an independent local inference method, revealing surprisingly potent capabilities. Synthesis Mode ($\Rightarrow$) Checking Mode ($\Leftarrow$) The essence of bidirectional checking lies in the flexible switching between modes. A typical function application This section is the final piece of our type checker, describing the entire bidirectional type checking process. 1. Synthesis - Variable (Synthesis-Var) $$
\frac{}{\Gamma \vdash x \Rightarrow \Gamma(x)} \quad (\text{S-Var})
$$ This is the synthesis rule for variables. To synthesize the type of a variable $x$, simply look up its declared type $\Gamma(x)$ in the current typing context $\Gamma$. This is the most straightforward form of type derivation: a variable’s type is its declared type. 2. Checking - Variable (Checking-Var) $$
\frac{\Gamma \vdash \Gamma(x) \lt: T}{\Gamma \vdash x \Leftarrow T} \quad (\text{C-Var})
$$ This is the checking rule for variables. To verify that variable $x$ conforms to expected type $T$, we first retrieve its actual type $\Gamma(x)$ from context $\Gamma$, then validate that this actual type $\Gamma(x)$ is a subtype of $T$. The subtype relation is denoted by $\Gamma \vdash \Gamma(x) \lt: T$. 3. Synthesis - Abstraction (Synthesis-Abs) $$
\frac{\Gamma, \overline{X}, \overline{x}:\overline{S} \vdash e \Rightarrow T}{\Gamma \vdash \textbf{fun} [\overline{X}] (\overline{x}:\overline{S}) e \Rightarrow \forall \overline{X}. \overline{S} \rightarrow T} \quad (\text{S-Abs})
$$ This is the synthesis rule for fully annotated function abstractions. 4. Checking - Unannotated Abstraction (Checking-Abs-Inf) $$
\frac{\Gamma, \overline{X}, \overline{x}:\overline{S} \vdash e \Leftarrow T}{\Gamma \vdash \textbf{fun} [\overline{X}] (\overline{x}) e \Leftarrow \forall \overline{X}. \overline{S} \rightarrow T} \quad (\text{C-Abs-Inf})
$$ This core rule infers parameter types for unannotated anonymous functions. 5. Checking - Annotated Abstraction (Checking-Abs) $$
\frac{\Gamma, \overline{X} \vdash \overline{T} \lt: \overline{S} \quad \Gamma, \overline{X}, \overline{x}:\overline{S} \vdash e \Leftarrow R}{\Gamma \vdash \textbf{fun} [\overline{X}] (\overline{x}:\overline{S}) e \Leftarrow \forall \overline{X}. \overline{T} \rightarrow R} \quad (\text{C-Abs})
$$ This rule handles annotated functions in checking mode. 6. Synthesis - Application (Synthesis-App) $$
\frac{\Gamma \vdash f \Rightarrow \forall \overline{X}. \overline{S} \rightarrow R \quad \Gamma \vdash \overline{e} \Leftarrow [\overline{T}/\overline{X}]\overline{S}}{\Gamma \vdash f [\overline{T}] (\overline{e}) \Rightarrow [\overline{T}/\overline{X}]R} \quad (\text{S-App})
$$ This is the synthesis rule for function application. 7. Checking - Application (Checking-App) $$
\frac{\Gamma \vdash f \Rightarrow \forall \overline{X}. \overline{S} \rightarrow R \quad \Gamma \vdash [\overline{T}/\overline{X}]R \lt: U \quad \Gamma \vdash \overline{e} \Leftarrow [\overline{T}/\overline{X}]\overline{S}}{\Gamma \vdash f [\overline{T}] (\overline{e}) \Leftarrow U} \quad (\text{C-App})
$$ This checking rule for function application adds a final subtype check. 8. Synthesis - Application - Infer Specification (Synthesis-App-InfAlg) $$
\frac{
\begin{array}{ccc}
\Gamma \vdash f : \forall \overline{X}.\overline{T} \to R & \Gamma \vdash \overline{e} : \overline{S} & |\overline{X}| > 0 \\
\emptyset \vdash_X \overline{S} \lt: \overline{T} \Rightarrow \overline{D} & C = \bigwedge \overline{D} & \sigma = \sigma_{CR}
\end{array}
}{
\Gamma \vdash f(\overline{e}) \Rightarrow \sigma R
}
\quad (\text{S-App-InfAlg})
$$ This synthesis rule infers missing type arguments in function applications, derived from the “Type Argument Calculation” section. 9. Checking - Application - Infer Specification (Checking-App-InfAlg) $$
\frac{\begin{matrix} \Gamma \vdash f \Rightarrow \forall \overline{X}. \overline{T} \rightarrow R \quad \Gamma \vdash \overline{e} \Rightarrow \overline{S} \quad |\overline{X}| \gt 0 \\ \emptyset \vdash \overline{S} \lt: \overline{T} \Rightarrow C \quad \emptyset \vdash R \lt: V \Rightarrow D \quad \sigma \in \bigwedge C \wedge D \end{matrix}}{\Gamma \vdash f(\overline{e}) \Leftarrow V} \quad (\text{C-App-InfAlg})
$$ This checking version of 10. Checking - Top Type (Checking-Top) $$
\frac{\Gamma \vdash e \Rightarrow T}{\Gamma \vdash e \Leftarrow \top} \quad (\text{C-Top})
$$ This rule switches from checking to synthesis mode. When checking $e$ against top type $\top$ (where all types are subtypes of $\top$), the check always succeeds. Since $\top$ provides no constraints, we directly synthesize $e$’s concrete type $T$. 11. Synthesis - Application - Bottom Type (Synthesis-App-Bot) $$
\frac{\Gamma \vdash f \Rightarrow \bot \quad \Gamma \vdash \overline{e} \Rightarrow \overline{S}}{\Gamma \vdash f [\overline{T}] (\overline{e}) \Rightarrow \bot} \quad (\text{S-App-Bot})
$$ Special case when function type is $\bot$ (bottom type), representing non-terminating expressions (e.g., exceptions). 12. Checking - Application - Bottom Type (Checking-App-Bot) $$
\frac{\Gamma \vdash f \Rightarrow \bot \quad \Gamma \vdash \overline{e} \Rightarrow \overline{S}}{\Gamma \vdash f [\overline{T}] (\overline{e}) \Leftarrow R} \quad (\text{C-App-Bot})
$$ Checking rule for $\bot$-typed functions. The final implementation centers on two key functions: The last critical objective is the design for local variable bindings, $$
\frac{\Gamma \vdash e \Rightarrow S \quad \Gamma, x:S \vdash b \Rightarrow T}{\Gamma \vdash \textbf{let } x = e \textbf{ in } b \Rightarrow T} \quad (\text{S-Let})
$$ $$
\frac{\Gamma \vdash e \Rightarrow S \quad \Gamma, x:S \vdash b \Leftarrow T}{\Gamma \vdash \textbf{let } x = e \textbf{ in } b \Leftarrow T} \quad (\text{C-Let})
$$ Implementation is left as an exercise. Thus far, following in the footsteps of Pierce and Turner, we have thoroughly dissected an elegant and powerful mechanism for local type inference. By combining the two core techniques of local type parameter synthesis and bidirectional type checking, we have ultimately completed the design of a truly robust type checker. This system not only theoretically handles powerful type systems like System $F_{\le}$—which integrates subtyping and impredicative polymorphism—but also practically resolves the most pervasive and vexing issues of type annotations arising from polymorphism, higher-order programming, and pure functional styles. Entirely grounded in the principle of “locality,” this algorithm exhibits predictable behavior, ease of implementation, and user-friendly error diagnostics, serving as an exemplary bridge connecting profound theory with everyday programming practice. Of course, this article only addresses the core aspects of the seminal paper. The original work explores a broader landscape, with its most significant extension being support for bounded quantification. Bounded quantification, expressed as $\forall (X \lt: T_2, Y \lt: T_2, \cdots) . e$, allows type parameters to be constrained by their supertypes. This feature is indispensable for precisely modeling advanced constructs like inheritance in object-oriented languages and represents a critical pathway toward greater expressive power. Chapter 5 of the original paper details how to extend the local inference technique discussed here to systems supporting bounded quantification. This extension renders the algorithm—particularly constraint generation and solving—more intricate and complex, introducing new challenges when handling interactions between the $\bot$ type and type bounds. Due to space constraints, a deeper examination of this topic must be deferred to future articles. Additionally, a notable limitation of this work is the absence of formal proofs. Our focus has been on elucidating the algorithm’s design motivations and intuitions while presenting its logic in a manner akin to practical implementation. Consequently, we omitted rigorous mathematical details—such as soundness and completeness proofs for the constraint generation algorithm—providing only brief summaries. Finally, it is worth emphasizing this work’s reliance on the algebraic properties of type systems—for instance, ensuring that the least upper bound (join) and greatest lower bound (meet) always exist between any types—which reveals a profound design principle: type systems with well-behaved algebraic structures often yield more elegant and powerful type inference algorithms. Since this paper’s publication, the quest for a more refined algebraic theory of subtyping has continued unabated. Subsequent research, such as Stephen Dolan’s “Algebraic Subtyping,” advances this intellectual trajectory by abstracting and simplifying the formalization of subtyping relations—a transformative contribution that, in the author’s view, redefines subtyping research. Naturally, exploring these cutting-edge developments exceeds the scope of interpreting this classic 2000 paper and remains a subject for future work. 1. How to Read Typing Rules [blog/lti-english/how_to_read][edit]
Please do not worry. These formal tools are not designed to be obscure; on the contrary,
they aim to achieve precision and unambiguity that natural language struggles to attain.
This section provides a simple guide to reading these rules. Readers already familiar with them may skip ahead.
they define what programs are well-structured and meaningful, and which are not.
$$\frac{\text{Premise}_1 \quad \text{Premise}_2 \quad ...}{\text{Conclusion}} \quad (\text{Rule Name})$$
The long horizontal line is the key to understanding everything.
Var or App.
“Given the known conditions in context $\Gamma$, we can infer (or prove) that program term $e$ has type $T$.” 2. Just Enough Type Information [blog/lti-english/enough][edit]
Int annotations in cons[Int](3, nil[Int]).
map(list, fun(x) x+1) only obscures the core logic, creating unnecessary distraction.
2.1. Strategies [blog/lti-english/strategies][edit]
The following are the two primary strategies introduced in this paper:
3. Language Specification [blog/lti-english/language][edit]
(Note: Overline denotes sequence notation $\overline{X} = X_1, X_2, \dots, X_n$; similarly, $\overline{x} : \overline{T} = x_1 : T_1, \dots, x_n : T_n$):
where Var implements type variables:pub(all) enum Type {
TyVar(Var)
TyTop
TyBot
TyFun(Array[Var], Array[Type], Type)
} derive(Eq)
pub(all) enum Expr {
EVar(Var)
EApp(Expr, Array[Type], Array[Expr])
EAbs(Array[Var], Array[(Var, Type)], Expr)
}
3.1. Subtyping Relation [blog/lti-english/subtype][edit]
The S-Fun rule captures the key characteristics of function subtyping: contravariant in argument types (reversed subtyping direction) and covariant in return type (preserved subtyping direction). Readers may attempt to translate this formalism into a MoonBit predicate function or refer to the folded code snippet below. 3.1.1. Solution [blog/lti-english/subtype_code][edit]
/// l <: r
struct Subtype {
l : Type
r : Type
}
pub fn Subtype::subtype(self : Subtype) -> Bool {
match (self.l, self.r) {
(TyVar(v1), TyVar(v2)) => v1 == v2
(_, TyTop) => true
(TyBot, _) => true
(TyFun(xs1, r, s), TyFun(xs2, t, u)) if xs1 == xs2 =>
subtypes(t, r) && { l: s, r: u }.subtype()
_ => false
}
}
3.1.2. Solution [blog/lti-english/subtype_code2][edit]
s | t and $S \wedge T$ as s & t. The definitions of these two operators are as follows:impl BitAnd for Type with land(s, t) {
match (s, t) {
(TyFun(xs1, v, p), TyFun(xs2, w, q)) if xs1 == xs2 => {
guard v.length() == w.length() else { TyBot }
TyFun(xs1, v.zip(w).map(z => z.0 | z.1), p & q)
}
(s, t) if s.subtype(t) => s
(s, t) if t.subtype(s) => t
_ => TyBot
}
}
impl BitOr for Type with lor(s, t) {
match (s, t) {
(TyFun(xs1, v, p), TyFun(xs2, w, q)) if xs1 == xs2 => {
guard v.length() == w.length() else { TyTop }
TyFun(xs1, v.zip(w).map(z => z.0 & z.1), p | q)
}
(s, t) if s.subtype(t) => t
(s, t) if t.subtype(s) => s
_ => TyTop
}
}
4. Explicit Typing Rules [blog/lti-english/explicit][edit]
Core Rules
This rule unifies traditional term abstraction and type abstraction.
This rule unifies traditional term application and polymorphic application. It first derives the type of function f, then verifies that all actual arguments (type and term arguments) conform to the function’s signature.
The requirement is relaxed: actual argument types need only satisfy the subtype relation with parameter types, not exact matches. 4.1. Type Parameter Substitution [blog/lti-english/subst_code][edit]
mk_subst function to generate a type substitution map,
and an apply_subst function that applies this map to a concrete type.
$[\overline{T}/\overline{X}]R$ is obtained through $\text{apply\_subst}(\text{mk\_subst}(\overline{X},\overline{T}), R)$.pub fn mk_subst(vars : Array[Var], tys : Array[Type]) -> Map[Var, Type] raise {
guard vars.length() == tys.length() else { raise ArgumentLengthMismatch }
Map::from_array(vars.zip(tys))
}
pub fn Type::apply_subst(self : Type, subst : Map[Var, Type]) -> Type {
match self {
TyVar(v) => subst.get(v).unwrap_or(self)
TyTop | TyBot => self
TyFun(xs, ss, t) => {
let fs = subst.iter().filter(kv => !xs.contains(kv.0)) |> Map::from_iter
let new_ss = ss.map(s => s.apply_subst(fs))
let new_t = t.apply_subst(fs)
TyFun(xs, new_ss, new_t)
}
}
}
Introducing the $\bot$ type necessitates this special application rule to maintain type soundness.
Since $\bot$ is a subtype of any function type, a $\bot$-typed expression should be applicable to any valid arguments without type errors. 5. Local Type Argument Synthesis [blog/lti-english/synthesis][edit]
App could compute the result type based on these arguments.
Now, to make the language simpler and more ergonomic, we allow omitting type arguments $\overline{T}$. We update the language construct as follows:pub(all) enum Type {
TyVar(Var)
TyTop
TyBot
TyFun(Array[Var], Array[Type], Type)
} derive(Eq)
pub(all) enum Expr {
EVar(Var)
EAppI(Expr, Array[Expr])
EApp(Expr, Array[Type], Array[Expr])
EAbs(Array[Var], Array[(Var, Type)], Expr)
EAbsI(Array[Var], Array[Var], Expr)
}
EAppI(Expr, Array[Expr]), corresponding to the type-argument-omitted form.
(For later discussion convenience, we also add the EAbsI construct here.)
We now require a new rule: 6. Constraint Generation [blog/lti-english/cg][edit]
we reframe it as a problem of “solving for unknown boundaries” similar to solving algebraic equations.
Instead of guessing what the type parameters $\overline{X}$ might be,
we derive the conditions $\overline{X}$ must satisfy by analyzing the subtyping relation itself.
which inherently implies constraints on its components (including unknown variables).
If $X$ is an unknown variable in $T$, the structure of $S$ necessarily restricts the possible forms of $X$.
Our algorithm transforms this implicit structural restriction into a set of explicit assertions about $X$’s bounds.
If not handled properly, the constraints we generate may themselves be ill-formed.
This challenge motivates the algorithm’s first concrete step: variable elimination. 6.1. Variable Elimination [blog/lti-english/var_elim][edit]
We suggest readers pause here to consider how to design recursive rules for these relations and implement them in code (hint: for promotion, if $X \in V$ then promote it to $\top$, otherwise leave it unchanged; other cases are straightforward). 6.1.1. Promotion and Demotion Rules [blog/lti-english/ve_rules][edit]
pub fn Type::promotion(self : Type, v : Set[Var]) -> Type {
match self {
TyFun(xs, ss, t) => {
let vP = v.iter().filter(x => !xs.contains(x)) |> Set::from_iter
let us = ss.map(s => s.demotion(vP))
let r = t.promotion(vP)
TyFun(xs, us, r)
}
TyVar(vr) if v.contains(vr) => TyTop
_ => self
}
}
pub fn Type::demotion(self : Type, v : Set[Var]) -> Type {
match self {
TyFun(xs, ss, t) => {
let vP = v.iter().filter(x => !xs.contains(x)) |> Set::from_iter
let us = ss.map(s => s.promotion(vP))
let r = t.demotion(vP)
TyFun(xs, us, r)
}
TyVar(vr) if v.contains(vr) => TyBot
_ => self
}
}
After ensuring scoping safety through variable elimination,
this step’s mission is to transform a subtyping judgment—for example,
$S \lt: T$ where $S$ or $T$ contains undetermined type parameters $\overline{X}$—into a set of explicit constraints on these unknowns $\overline{X}$.
We now formally define the structure of constraints in code:
6.2. Constraint and Constraint Set Code [blog/lti-english/cg_def_code][edit]
struct Constraints(Map[Var, Subtype])
pub fn Constraints::empty() -> Constraints {
Constraints(Map([]))
}
fn Constraints::items(self : Constraints) -> Map[Var, Subtype] {
let Constraints(items) = self
items
}
pub fn Constraints::meet(
self : Constraints,
other : Constraints,
) -> Constraints {
Constraints(
union_with(self.items(), other.items(), (l, r) => {
let { l: l1, r: r1 } = l
let { l: l2, r: r2 } = r
{ l: l1 & l2, r: r1 | r2 }
}),
)
}
pub fn meets(c : Array[Constraints]) -> Constraints {
c.iter().fold(init=Constraints::empty(), Constraints::meet)
}
$$
\frac{Y \in \overline{X} \quad S \Downarrow^V T \quad \text{FV}(S) \cap \overline{X} = \emptyset}{V \vdash_{\overline{X}} Y \lt: S \Rightarrow \{ \bot \lt: Y \lt: T \}} \quad (\text{CG-Upper})
$$
Note that the variable elimination operation ($S \Downarrow^V T$) ensures the upper bound $T$ is well-formed.
$$
\frac{Y \in \overline{X} \quad S \Uparrow^V T \quad \text{FV}(S) \cap \overline{X} = \emptyset}{V \vdash_{\overline{X}} S \lt: Y \Rightarrow \{ T \lt: Y \lt: \top \}} \quad (\text{CG-Lower})
$$
$$
\frac{V \cup \{\overline{Y}\} \vdash_{\overline{X}} \overline{T} \lt: \overline{R} \Rightarrow \overline{C} \quad V \cup \{\overline{Y}\} \vdash_{\overline{X}} S \lt: U \Rightarrow D}{V \vdash_{\overline{X}} \forall \overline{Y}.\overline{R} \to S \lt: \forall \overline{Y}.\overline{T} \to U \Rightarrow (\bigwedge \overline{C}) \wedge D} \quad (\text{CG-Fun})
$$
Here, constraints are accumulated through intersection ($\wedge$) of sub-constraint sets. 6.3. Constraint Generation Code [blog/lti-english/cg_code][edit]
/// Pre-condition: xs and v are disjoint sets of variables.
pub fn Subtype::generate(
self : Subtype,
xs : Array[Var],
v : Set[Var],
) -> Constraints raise {
match (self.l, self.r) {
(_, TyTop) | (TyBot, _) => Constraints::empty()
(TyVar(y1), TyVar(y2)) if y1 == y2 && !xs.contains(y1) =>
Constraints::empty()
(TyVar(y), s) if xs.contains(y) => {
let t = s.demotion(v)
Constraints(Map::from_array([(y, { l: TyBot, r: t })]))
}
(s, TyVar(y)) if xs.contains(y) => {
let t = s.promotion(v)
Constraints(Map::from_array([(y, { l: t, r: TyTop })]))
}
(TyFun(ys1, r, s), TyFun(ys2, t, u)) if ys1 == ys2 => {
let vy = v.union(Set::from_array(ys1))
let cs = t.zip(r).map(tr => { l: tr.0, r: tr.1 }.generate(xs, vy))
|> meets()
cs.meet({ l: s, r: u }.generate(xs, vy))
}
_ => raise ConstraintGenerationError
}
}
7. Calculating Type Arguments [blog/lti-english/calc_args][edit]
7.1. Polarity [blog/lti-english/polarity][edit]
The polarity of a type variable $X$ in another type expression $R$ describes how the type of the entire expression changes accordingly when the type of that variable becomes larger or smaller.
and we only need to pay attention to the position of variables in function types (whether on the left or right of the arrow),
while also considering nested function structures. It is recommended that the reader pause here to think about the design of this algorithm.
Of course, you can also directly expand the code block below to view the specific implementation. 7.1.1. Variance Code [blog/lti-english/variance_code][edit]
pub fn Type::variance(self : Type, va : Var) -> Variance {
letrec go = (ty : Type, polarity : Bool) => {
match ty {
TyVar(v) if v == va => if polarity { Covariant } else { Contravariant }
TyVar(_) | TyTop | TyBot => Constant
TyFun(_, params, ret) => {
let param_variance = params
.map(p => go(p, !polarity))
.fold(init=Constant, combine_variance)
combine_variance(param_variance, go(ret, polarity))
}
}
}
and combine_variance = (v1 : Variance, v2 : Variance) => {
match (v1, v2) {
(Constant, v) | (v, Constant) => v
(Covariant, Covariant) | (Contravariant, Contravariant) => v1
(Covariant, Contravariant) | (Contravariant, Covariant) => Invariant
(Invariant, _) | (_, Invariant) => Invariant
}
}
go(self, true)
}
App-InfSpec where no unique optimal solution can be found. 7.2. Solving Constraints [blog/lti-english/solve_code][edit]
pub fn Constraints::solve(self : Constraints, ty : Type) -> Map[Var, Type]? {
let f = (vs : (Var, Subtype)) => {
let { l, r } = vs.1
let v = match ty.variance(vs.0) {
Constant | Covariant => Some(l)
Contravariant => Some(r)
Invariant => if l == r { Some(l) } else { None }
}
v.map(v => (vs.0, v))
}
let constraints = self.items()
let solved = constraints.iter().filter_map(f).to_array()
guard solved.length() == constraints.length() else { None }
Some(Map::from_array(solved))
}
7.3. Proof Sketch [blog/lti-english/proof_eq][edit]
App-InfSpec specification?
App-InfSpec specification. It allows us to ultimately replace that non-executable specification with a fully algorithmic rule App-InfAlg: 8. Bidirectional Checking [blog/lti-english/bidirectional][edit]
x in fun[](x) x+1) within anonymous functions?let x = ...) without explicit type annotations?fun[](x) x+1 contain no substructures that provide type information for x.Two Modes: Synthesis and Checking
f(e) perfectly illustrates this process: the type checker first synthesizes the type of function f, then uses this information to switch modes and check the argument e. 8.1. Typing Rules [blog/lti-english/bidi_rules][edit]
Variable Rules
Function Abstraction Rules
fun [X] (x:S) e where type parameters $\overline{X}$ and value parameter types $\overline{S}$ are explicitly annotated.
fun [X] (x) e against expected type $\forall \overline{X}. \overline{S} \rightarrow T$, we extract parameter types $\overline{S}$ and return type $T$ from the expected type.
fun [X] (x:S) e against expected type $\forall \overline{X}. \overline{T} \rightarrow R$:
Function Application Rules
S-App: synthesize $f$’s type and check arguments $\overline{e}$.
Rules Combining Bidirectional Checking and Type Argument Synthesis
App-InfAlg describes constraint-solving for type parameters.
Top and Bottom Types
synthesis and check.
This is the most crucial exercise in this article.
Readers are strongly encouraged to implement these functions themselves to appreciate the elegance of bidirectional checking
and learn techniques for translating type rules into code. 8.2. Bidirectional Typing Code [blog/lti-english/bidi_code][edit]
pub fn Expr::synthesis(self : Expr, gamma : Context) -> Type raise {
match self {
EVar(v) => gamma[v]
EAbs(xs, vs, body) => {
let ctx = gamma.append_vars(xs).append_binds(vs)
let body_ty = body.synthesis(ctx)
TyFun(xs, vs.map(xS => xS.1), body_ty)
}
EAppI(f, e) => {
let t = f.synthesis(gamma)
guard !(t is TyBot) else { TyBot }
guard t is TyFun(xs, t, r) else { raise SynthesisError }
guard e.length() == t.length() else { raise ArgumentLengthMismatch }
// guard !xs.is_empty() else { raise NotFound }
let es = e.map(expr => expr.synthesis(gamma))
let cs = es.zip(t).map(z => { l: z.0, r: z.1 }.generate(xs, Set([])))
let sigma = meets(cs).solve(r)
sigma.map(s => r.apply_subst(s)).unwrap_or_error(SynthesisError)
}
EApp(f, ty, e) => {
let t = f.synthesis(gamma)
guard !(t is TyBot) else { TyBot }
guard t is TyFun(xs, ss, r) else { raise SynthesisError }
let subst = mk_subst(xs, ty)
guard e.length() == ss.length() else { raise ArgumentLengthMismatch }
e.zip(ss).each(es => es.0.check(es.1.apply_subst(subst), gamma))
r.apply_subst(subst)
}
_ => raise SynthesisError
}
}
pub fn Expr::check(self : Expr, ty : Type, gamma : Context) -> Unit raise {
match (self, ty) {
(_, TyTop) => ()
(EVar(v), t) => {
let gx = gamma[v]
guard gx.subtype(t) else { raise CheckError }
}
(EAbs(xs, vs, body), TyFun(xsP, t, r)) if xs == xsP => {
let ctx = gamma.append_vars(xs)
guard t.length() == vs.length() else { raise CheckError }
guard t.zip(vs.map(xS => xS.1)).iter().all(z => z.0.subtype(z.1)) else {
raise CheckError
}
body.check(r, ctx.append_binds(vs))
}
(EAbsI(xs, vs, body), TyFun(xsP, ss, t)) if xs == xsP &&
ss.length() == vs.length() =>
body.check(t, gamma.append_vars(xs).append_binds(vs.zip(ss)))
(EAppI(f, e), u) => {
let t = f.synthesis(gamma)
guard !(t is TyBot) else { () }
guard f.synthesis(gamma) is TyFun(xs, t, r) else { raise CheckError }
guard e.length() == t.length() else { raise ArgumentLengthMismatch }
guard !xs.is_empty() else { () }
let es = e.map(expr => expr.synthesis(gamma))
let cs = es.zip(t).map(z => { l: z.0, r: z.1 }.generate(xs, Set([])))
let d = { l: r, r: u }.generate(xs, Set([]))
guard meets(cs).meet(d).satisfiable() else { raise CheckError }
}
(EApp(f, ty, e), u) => {
let t = f.synthesis(gamma)
guard !(t is TyBot) else { () }
guard t is TyFun(xs, ss, r)
let subst = mk_subst(xs, ty)
guard e.length() == ss.length() else { raise ArgumentLengthMismatch }
e.zip(ss).each(es => es.0.check(es.1.apply_subst(subst), gamma))
guard r.apply_subst(subst).subtype(u) else { raise CheckError }
}
_ => raise CheckError
}
}
which requires introducing a new syntactic construct ELet. Its rules are straightforward: 9. Conclusion and Future [blog/lti-english/conclusion][edit]