While working on DL2's first HM type inference, I wanted to put down some notes on bidirectional typing.
Bidirectional typing is a system which combines type inference with type checking. Specifically, it augments type checking with rules for exactly when types can be synthesized or inferred. The hope is to get a simple implementation of type inference which matches the straight-forwardness of type checking. The attached paper goes into the theoretical reasons, but ignores some that might motivate implementers:
>checker-inferencer's structure is easy to write and obvious: it is syntax-directed, like many functional programs, and can be written with the natural recursive structure
>does not require unification for STLC inference
>error messages can be reported exactly where they're discovered
>as described below, "bare lambda" is "unlikely" to occur in the function position of an apply, so STLC inference will almost always succeed, requiring only the declarations of the types of each relevant constant
The typical approach (according to the paper, "Pfenning Recipe"), is to have constructors check and to have constants, variables and destructors synthesize. In the specific case of "lambda" and "apply", this means that "lambda" is checked and "apply" synthesizes. This strategy then directs the rest of the rules. In the case of apply, synthesis requires that the function's type is synthesized, but once the function type is synthesized the argument only needs to be checked. The types of all variables are known up-front, since lambda can only ever be checked.
There's an obvious issue with this approach: what do you do about instances of polymorphic constants? One of the most important uses of type inference in practice is to allow for using polymorphic constants without supplying types. The recipe described does not, on its own, allow for inferring the type arguments to a polymorphic constant, even when restricting to only prefix-polymorphic constants. It's necessary to re-introduce type variables and unification. Of course, once you have type variables and unification, you already have a major component of Hindley-Milner type inference (i.e. "the hard part"). At that point, the obvious question is: "why bother if HM does everything?" I'm a skeptic regarding the approach, but in relation to DL2 there are some interesting aspects.
The work on bidirectional typing is a good hint as to what approaches are likely to work for practical type inference. Since DL2 is more similar to GHC's Core than Haskell itself, DL2 can accept many possible type inference mechanisms. Both bidirectional typing and unification-based inference bear similarities to logic programming without non-determinism or with limited non-determinism. Bidirectional typing tends to follow the hints of logic programming, specifically in suggesting that there are multiple modes of type-checking. At no point in the "original" bidirectional typing is it needed to have either non-determinism or unification, but modes from logic programming are still relevant. Meanwhile, unification-based inference takes complete expressions and uses only unification to derive the most general type.
Bidirectional typing's error-checking utility is another reason why it's interesting from a DL2 perspective. The restricted format of bidirectional typing means that error-checking is guaranteed to involve local judgments, unless extended with unification. There may or may not be something to take there.
When working on DL2's HM-based macro-expander, I thought about how to work with function application. It occurred to me that, if the macro-expander took an input "return type", then it would be possible to synthesize a non-polymorphic function type from just the arguments and without generating a new type variable. This made me think about bidirectional typing and find the above paper. This idea doesn't seem entirely distinct from bidirectional typing, especially the "simultaneous input and output" system described in the attached paper. DL2 macro-expanders also inherently suggest a requirement for locality, which is one of the strengths of bidirectional types.
DL2 type-aware macro-expansion may be able to take advantage of incoming type information. It would be possible to supply variable types in binders for "match" and "case" without resorting to unification. The use of simultaneous input and output typing would allow for "un-satisfiable input type" errors, at least. Different type inference scenarios could have different specific macros. Type annotations could be used by users to direct macro behavior, even non-locally.
The idea of such a "bi-HM" DL2 is more of a curiosity than an actual long-term plan. It might end up that its only utility is in eliminating unification, or there might be some obscenely clever technique that lets it provide high-quality errors. It might suggest a "combined meta-theory" of type inference that has little utility in practice.
Some reasons to think this might not be useful:
>should "checked apply" expand its arguments in a different order from "regular apply" just to avoid unification?
>would abuse of type information make the system unpredictable at a user level?
>could trying to be too clever with error messages make them worse in practice?
>does knowing extra type information help much in practice?
I'm half-considering trying a variant DL2 macro-expander and set of macros operating primarily according to Pfenning's recipe, and use it as a comparison point for error handling with other macro-expanders, although I'd have to find a decent set of "incorrect programs" to put different systems through their paces.