Bidirectional Checking [blog/lti-english/bidirectional][edit]
Bidirectional Checking [blog/lti-english/bidirectional][edit]
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:
- Convenience of Higher-Order Programming: How to infer the type of bound variables (e.g.,
xinfun[](x) x+1) within anonymous functions? - Support for Purely Functional Style: How to enable extensive local variable bindings (e.g.,
let x = ...) without explicit type annotations?
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 fun[](x) x+1 contain no substructures that provide type information for x.
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.
Two Modes: Synthesis and Checking
-
Synthesis Mode ($\Rightarrow$)
- In this mode, type information propagates bottom-up.
- Its goal is to compute (or “synthesize”) the type of an expression based on the types of its subexpressions.
- This corresponds to traditional type checking and applies to contexts where the expected type is unknown, such as top-level expressions or the function part of an application node.
-
Checking Mode ($\Leftarrow$)
- In this mode, type information propagates top-down.
- Its goal is to verify (or “check”) whether an expression satisfies an “expected type” (or a subtype thereof) provided by its context.
- This mode activates when the context already determines the expression’s type.
The essence of bidirectional checking lies in the flexible switching between modes. A typical function application 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.
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. 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
The final implementation centers on two key functions: 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.
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
}
}
The last critical objective is the design for local variable bindings,
which requires introducing a new syntactic construct ELet. Its rules are straightforward:
$$ \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.