Hello,
I am quite a newbie to Crystal. On many aspects, I find that its design is very smart. But one thing really hurts me: its type-checking is not compositional. Let me detail this on the following example:
def demo(c : Class) : Nil
a = c.new()
puts("demo on #{a.class}")
(1..10).each {|x| a.push(x*x)}
puts(a)
end
demo(Array(Int32))
demo(Array(Float64))
This program is well-typed. But, if we add the following line, it becomes ill-typed.
demo(Array(Bool))
And the type-checker declares an issue in the definition of demo and not on the call with the issue.
4 | (1..10).each {|x| a.push(x*x)}
^
Error: expected argument #1 to âArray(Bool)#pushâ to be Bool, not Int32
In all other strongly-type language that I know, type interfaces of methods/functions are contracts: their validity is checked on function definitions - and only the type interface is used for typing function calls : this makes type-checking compositional.
This is not the case for Crystal. Type restrictions are not contract: they only restrict call contexts (and they are used as guards for multiple dispatch).
Having a non-compositional type checking has huge consequences: this makes debugging harder, this makes incremental compilation impossible, which has itself other consequences, such as very slow compilation times, and poor interactions with LSP-server.
It seems difficult to make Crystal type-checking compositional without loosing too much expressiveness. For example, on the above example we would need to make demo a generic method over a type T, such that T <= (Int32 | Float64) and c <= Array(T). Including such constraints in the language and its type-checking seems really challenging. Such complex constraints might not be inferred and should be instead provided by programmers.
Moreover, backward compatibility would be broken (which can be mitigated with a flag on the compiler). But, the benefits may worth the price of these difficulties ?