• I don’t think this approach requires introducing new keywords for all these operations. You could instead add an infer keyword for type parameters that can always be inferred from the types of the function arguments. So map would change from:

    map : ∀(a b : Type) → (a → b) → List a → List b
    

    into:

    map : ∀(infer a : Type, infer b : Type) → (a → b) → List a → List b
    

    When calling map, you omit the types a and b and they’re instead inferred from the type of the first argument (a →b).

    This gives a more reasonable set of limitations:

    • It doesn’t play well with automatic currying, since a curried argument might be required to infer some type parameters. (So the map type signature above is a little misleading, it shouldn’t have nested arrows.)
    • You can only infer type parameters that are fully determined by the function arguments. Putting infer on a type parameter that cannot be so inferred would give a type error on the function definition. Instead you would omit the infer, and the caller will have to specify the type parameters explicity. But these are more complex functions so it’s more reasonable to require annotations on them.

    score 8 · canonical

    • Does this model infer nicely if you have an a -> b in the first argument? I’m thinking in particular if the passed in argument has infer fields. I suppose that you could have a syntax for explicitly passing in a and b in some cases to help the checker monomorphize properly…


      score 2 · canonical

      • Yeah, I think that works fine. Say you have the type signatures:

        map : ∀(infer a : Type, infer b : Type) → (a → b) → List a → List b
        make-adder : ∀(infer c: Type, a implements Plus) → c -> (c → c)
        make-adder x = lambda (y: a) x + y
        

        and you call:

        map (make-adder 10) [1, 2, 3]  // evaluates to [11, 12, 13]
        

        First you infer c = Int because the argument 3 has type Int, then you infer a = Int, b = Int because (make-adder 10) has type Int -> Int. Note that we’re not doing general unification here: all of these inferences are always “type variables = concrete types”. You can never get a constraint of the form a = b like you do with unification.

        More detail about how I imagine this all working:

        • At every use site of a variable, that variable’s type is known.
        • Therefore, arguments to lambdas and functions require type annotations. Unless, as the blog post suggests, there’s already an expected type for the lambda that comes from the context it’s placed in. (E.g. if you pass a lambda to a function, and that function requires it to have a fixed type (no type vars), you can omit the types of the arguments to the lambda.)
        • The return value of a function or lambda can be inferred by type checking its body, though I think we’ve correctly settled as a community on the requirement that function return types must be annotated (though not lambda return types).
        • Given all of the above, you can nearly always tell the type of an expression given only the types of the variables that appear in it. The exception is, as just mentioned, that if the context an expression is in requires it to have a certain type directly (nothing fancy like “it must have the same type as that other argument”), then that required type can be used to disambiguate otherwise ambiguous types (like a lambda not annotating the types of its arguments).

        score 3 · canonical

  • What I’m wondering here, is how many rules would go away if instead of giving up on unification altogether, we stuck to local type inference? That would eliminate fewer rules for sure, but how few exactly?

    There’s also the question of where the bloat comes from exactly: is it from unification alone, or is it from how unification interacts with everything else? If it’s the latter, then keeping “everything else” small could very well help us keeping a small type system. Personally I wouldn’t ask for much: basic built-in types, sum & product types, a built in array if singly linked lists aren’t enough… and not much else to be honest. Type classes maybe, existential quantification perhaps, and I’d skip inheritance for sure.


    score 4 · canonical

  • For what it’s worth, unification should not be difficult to implement; a bare-bones unification monad without backtracking should take fewer than 100 lines of Haskell, using the µKanren recipe. The type-checker can then be written as type judgements, taking one or two lines for each production. I say this because I don’t like to think of folks having to maintain thousands of lines of excess compiler infrastructure.

    For example, consider that Haskell is terser than RPython. In my implementation of Cammy, it takes 148 lines to implement the µKanren core (sampler/cammylib/types.py) and about another 126 lines to type-check 39 productions (sampler/cammylib/arrows.py), without macros. Haskell is also terser than Scheme. The module implementing relational type-checking (allowing type-driven program synthesis) is 289 lines (movelist/cammyo.scm), of which 133 lines are type-checking and the rest are evaluation.


    score 10 · canonical

    • One frustration I’ve had with using a unification monad is having to open up the datatypes to make room for logic variables.

      For example, if my language is:

      type Name = String
      data Expr = Const Int | Var Name | Lam Name Expr | App Expr Expr
      data Type = TInt | TFun Type Type
      

      and I want to use unification, I have to add a case for logic variables:

      data Type = TInt | TFun Type Type | TVar LogicVar
      

      which is unsatisfying because LogicVar isn’t part of my language; it’s part of the typechecking machinery.

      I’ve also seen this pattern recommended (from unification-fd probably), using open recursion to keep the logic variables separate:

      data TypeF a = TInt | TFun a a
      type Type = Fix TypeF
      type TypecheckerType = UTerm TypeF
      

      but I didn’t really like this either… I think because the Fix wrapper added noise:

      -- before
      example :: Type
      example = TFun TInt (TFun TInt TInt)
      
      -- after
      example :: Type
      example = Fix (TFun (Fix TInt) (Fix (TFun (Fix TInt) (Fix TInt))))
      

      So if avoiding unification means I don’t need a representation for partially-fleshed-out types, that still seems appealing.

      What’s your opinion or recommendation on this? For Cammy, maybe it wasn’t a problem because the logic programming is embedded in a dynamically typed language?


      score 3 · canonical

      • This is a great example! It’s relevant to my work too: I have an RPython implementation that I want to make generic, so that other interpreters can use it, but it’s too tightly tied to a single interpreter’s classes.

        So, Najd & Peyton Jones call this the “tree decoration problem.” Great name! Their attempt at solving it is called Trees that Grow and there’s a plan for implementing it in GHC. A closely-related problem, the “AST typing problem,” shows up here and has discussion at Lambda The Ultimate, from Yang, and from McKenna; note that we aren’t putting types on ASTs, but putting types on logic variables which are “interpreted” by generic traversals.

        I cheat the problem in multiple ways. In RPython for Monte, there are no types; instead, objects guard each other, and are referred to with generic fat pointers to objects. In RPython for Cammy, I hand-wrote a specialized µKanren which only works for Cammy’s type grammar, and I monomorphize when emitting bytecode. In CHICKEN Scheme for Cammy, I take full advantage of CHICKEN’s flexible encoding to intermix logic variables and AST fragments with user-level values.


        score 3 · canonical

    • I think the interesting core here is that if you drop unification you still can express a lot, and it doesn’t prevent you from showing up with unification later.

      From a practical perspective it’s a nice trick, I think. Though the microKanren paper looks super interesting, and I’ll definitely be trying to work through it.


      score 2 · canonical

      • I assumed that as well. For comparison, Grace appears to have 15 productions in the concrete syntax, so I would hope that it only takes about 60 lines of code to type-check it; using Pareto-style reasoning, maybe 120 lines at most.


        score 1 · canonical

    • There’s a related problem, which is how unification interacts with type errors. If you’re doing pure unification, you realize at some arbitrary point in time that a = Maybe Int but also a = Maybe String. Where did these come from? Which one is wrong? If one is from a type signature and the other is from a value, then the type error should be at the location of the value, because it didn’t have the expected type. They could also both be from type signatures, or both from values. How is this provenance information tracked? What do you do when the type mismatch came from a chain of reasoning 7 steps long each at different source locations? What happens when there are higher-kinded types – I don’t even know what questions to ask in that case, but imagine they’re tricksy questions.

      I always give type signatures to functions in Haskell, in part to improve the error messages. And I imagine it’s not trivial for its error messages to be as good as they are, given that it’s doing unification.


      score 8 · canonical

      • Yeah, ergonomics is important. For what it’s worth, it should be easy to extend the linked recipe to add annotations to logic variables, such that we get a list of annotations upon (dis)unification; this is like turning the logic monad into a union-find/disjoint-set monad. I haven’t seen anybody actually do this, though.


        score 3 · canonical

  • This is a lot of extra steps for just writing code in System F, or one of its numerous variants, aka GHC’s Core. System F is only type checked, there’s no inference. You don’t need to play complex games with having keywords or anything like that. Exactly what you need to annotate is well defined.


    score 2 · canonical

  • Props to them for rethinking the assumptions implicit in the technology! And more props to them for actually being able to comprehend the “Complete And Easy…” paper, which has in my experience been described as neither of those things by most people who’ve read it.

    It’s entertaining that the proposed solution is to just special-case a bunch of compiler-defined functions, since that’s exactly how Pascal-family languages handled most things that would otherwise require generics. (And to a lesser extent C-family languages, with things like sizeof().) And of course basically how Go did it for a long time too. So, like, it works, it works pretty well! Sure it kinda sucks sometimes, but it doesn’t prevent you from writing anything you need done. Unfortunately there is a certain type of person, such as myself, who absolutely hates the idea of making built-in functions with interfaces that the compiler is allowed to define but the user is not.

    However, a thought occurs to me when they outline the steps necessary for this:

    • add a bunch of built-in functions…
    • add special type-checking rules for those built-in functions when they’re fully saturated with all of their arguments…
    • still require explicit type abstraction when these built-in functions are not fully saturated…

    …is there any particular reason you can’t make these rules apply to user-defined functions anyway? I mean, start with a pluggable system that lets people define their own judgements into the type checker, the same way that Haskell’s operator overloading plugs new functions into the parser. Then realize that the type signature of a function is just a shortcut for doing that already? I thiiiiiiiink the cost of this is that type information won’t propagate outside the function, so if you have fn foo(T) -> T; let x = foo(3i32); let y = x + whatever; then the type checker won’t know that x is actually an i32. At least in some cases. I dunno, I’m bad at this stuff.

    (…wait, there’s actually a link at the end describing someone else doing exactly that. Welp!)

    The example of if/else blocks is a good case, since in most languages with lambdas and a good optimizer there’s zero reason for if to be a builtin rather than a function, as far as the language cares. The real reason it’s a keyword is not for type checking but because it delays evaluation, and doing that by hand with explicit zero-argument lambdas all the time feels like unnecessary work unless the language makes it really simple. It works just fine though; see Smalltalk, many Forth-y things, etc.

    … and if the user were to ask you “How do I define a twice synonym for replicate 2” you would just tell them “Don’t do that. Use replicate 2 directly.”

    Yeah, this is entirely practical and very much the cause of the Feels Bad Man™ vibes that make me hate this idea. :-P I would expect this sort of language to very rapidly grow a macro system to paper over these holes, and then you’d spend a lot of your time programming in untyped macros. Kiss the good error messages and coherent semantics goodbye!

    I think this “keyword type checking” can potentially occupy a “low tech” point in the type checking design space for functional programming languages that need to have efficient and compact implementations (e.g. for ease of embedding). Also, this can potentially provide a stop-gap solution for novice language implementers that want some sort of a type system but they’re not willing to commit to implementing a unification-based type system.

    Amen! <3


    score 16 · canonical

    • Regarding the foo(T) -> T example, I believe the compiler would be able to infer that x is an i32 without unification, whether or not foo was a built-in function or a user-defined function.

      In the case of a built-in function, there would be a judgment like:

      Γ ⊢ x ⇒ a
      ──────────────
      Γ ⊢ foo(x) ⇒ a
      

      … and in the case of a user-defined function, the foo function would require an explicit type application (e.g. foo<i32>(x)) and that would provide the information necessary to infer the type of the result. Obviously, as the post admits, it’s not ergonomic but it does work (this is basically exactly how Dhall works, and Dhall also does not support unification).

      I agree with the rest of what you’re saying, though.


      score 4 · canonical

    • Yes, I agree with your sentiment. Also:

      I thiiiiiiiink the cost of this is that type information won’t propagate outside the function

      And ofc that applies to the function. itself, i.e. what will be the type of the variable that you store this function into? It opens a box of lots of issues, which is why I have the same feeling as you: those exceptional built-in things break my intuition about how things work, which is very annoying.


      score 2 · canonical