# Incremental static typechecking

**URL:** <https://forum.crystal-lang.org/t/incremental-static-typechecking/8485>\
**Category:** Crystal Contrib\
**Created:** [October 16, 2025, 9:33am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485 "2025-10-16T09:33:48Z")\
**Posts on this page:** 14\
**Page:** 1

<div class="post-metadata">

**Author:** ![boulme](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/boulme/32/3054_2.png) [@boulme](https://forum.crystal-lang.org/u/boulme)\
**Post date:** [October 16, 2025, 9:33am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/1 "2025-10-16T09:33:48Z")

</div>

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:

```crystal
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.

```crystal
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 ?

---

<div class="post-metadata">

**Author:** ![Fulgurance](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/fulgurance/32/1463_2.png) [@Fulgurance](https://forum.crystal-lang.org/u/Fulgurance)\
**Post date:** [October 16, 2025, 9:45am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/2 "2025-10-16T09:45:37Z")

</div>

> [@boulme](#):
>
> `demo(Array(Bool))`

I am not sure to understand why you try to push an Int value inside an Array of Bool

But anyway, the raised error by the compiler is quite clear. What do you try to explain ?

---

<div class="post-metadata">

**Author:** ![boulme](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/boulme/32/3054_2.png) [@boulme](https://forum.crystal-lang.org/u/boulme)\
**Post date:** [October 16, 2025, 10:10am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/3 "2025-10-16T10:10:58Z")

</div>

I am trying to say that, on the contrary to most strongly-typed languages, Crystal type-checker does not check the definitions of methods, but only their call.

As a consequence, this makes debugging harder. Consider this second example, this is only when you decomment line `pay(1000)` that you find the bug in `pay`. For a similar example in Java, the compiler would reject `pay` directly on the definition.

```crystal
def pay(x : Int32) : Nil
  if x < 0
    bug(x) # not defined will failed !
  end
end
puts("Hello!")
puts("Hey, type hello please :)")
answer : String | Nil = gets
if answer == "hello please :)"
  puts("Congrat, you win 1000$...")
  # pay(1000) # <- decommenting this line will makes the typechecking fail
              # even if the runtime-error is unreachable
end

```

Other consequences include non-incremental compilation for which there is another thread. My point is that you cannot have incremental compilation without incremental static typechecking. And having incremental typechecking in Crystal is challenging: see my previous example.

Is my point clearer ?

---

<div class="post-metadata">

**Author:** ![Fulgurance](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/fulgurance/32/1463_2.png) [@Fulgurance](https://forum.crystal-lang.org/u/Fulgurance)\
**Post date:** [October 16, 2025, 11:20am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/4 "2025-10-16T11:20:37Z")

</div>

I got you now thank you. I agree with you, it’s something I find a bit annoying with crystal too

---

<div class="post-metadata">

**Author:** ![Blacksmoke16](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/blacksmoke16/32/1241_2.png) [@Blacksmoke16](https://forum.crystal-lang.org/u/Blacksmoke16)\
**Post date:** [October 16, 2025, 1:24pm UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/5 "2025-10-16T13:24:23Z")

</div>

> [@boulme](#):
>
> In all other strongly-type language that I know

Yea, my 2 cents on this is Crystal is somewhat unique in this regard. Types are not (always) _required_ like they are in other strongly-typed languages. E.g. in you example the `Class` restriction is essentially not doing anything and would be the same as if you didn’t provide it at all. Ultimately this makes the method be duck typed to whatever types support the methods called on the argument.

This provides a lot of flexibility in that you can just write you code and the compiler will figure it out and feel more dynamic, while still preventing you from actually hitting a runtime error like you would in Ruby/Python/PHP.

If you make the method definition like `def demo(c : Array(Int32 | Float64).class) : Nil` then you’d get:

```crystal
In test.cr:10:6

 10 | demo(Array(Bool))
           ^
Error: expected argument #1 to 'demo' to be Array(Float64 | Int32).class, not Array(Bool).class

```

When adding in the other type.

> [@boulme](#):
>
> `even if the runtime-error is unreachable`

_Technically_ the nature of the language makes this theoretically untrue, like a super contrived/naive example:

```crystal
struct Int32
  def <(other : Int32)
    return true if self == 1000

    previous_def
  end
end

def pay(x : Int32) : Nil
  if x < 0
    pp "oops"
  end
end

```

I.e. given you can override the `#<` method, the compiler just can’t _prove_ that `1000 < 0` at compile time. To me it just shows that static typing can only provide so much safety and isn’t an excuse to just _not_ write tests that would have caught this, and asserted it’s doing what you expect.

---

<div class="post-metadata">

**Author:** ![boulme](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/boulme/32/3054_2.png) [@boulme](https://forum.crystal-lang.org/u/boulme)\
**Post date:** [October 16, 2025, 2:19pm UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/6 "2025-10-16T14:19:33Z")

</div>

> [@Blacksmoke16](#):
>
> This provides a lot of flexibility in that you can just write you code and the compiler will figure it out and feel more dynamic, while still preventing you from actually hitting a runtime error like you would in Ruby/Python/PHP.

Yes, as far as I understand, Crystal gives this guarantee only for “complete” programs (eg with a “main” code). There is no guarantee on the classes in a well-typed program that you reuse in another context.

> [@Blacksmoke16](#):
>
> If you make the method definition like `def demo(c : Array(Int32 | Float64).class) : Nil` then you’d get:

Thanks, for this feedback. I did not thought to this kind of typing constraint. Thus, specifying typing contracts for Crystal methods within the current type system might be easier than I initially thought.

On your last remark, what I meant in my comment, was that the runtime-error is unreachable in this particular “complete” program.

> [@Blacksmoke16](#):
>
> > [@boulme](#):
> >
> > `even if the runtime-error is unreachable`
> 
> _Technically_ the nature of the language makes this theoretically untrue, like a super contrived/naive example:

Of course, I am happy that the type-checker detects that this call to `pay` could lead to a runtime-error in some contexts. I would be even happier, if it could give me this information without having to write a program that calls `pay`.

---

<div class="post-metadata">

**Author:** ![boulme](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/boulme/32/3054_2.png) [@boulme](https://forum.crystal-lang.org/u/boulme)\
**Post date:** [October 16, 2025, 3:02pm UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/7 "2025-10-16T15:02:34Z")

</div>

> [@Blacksmoke16](#):
>
> To me it just shows that static typing can only provide so much safety and isn’t an excuse to just _not_ write tests that would have caught this, and asserted it’s doing what you expect.

I completely agree. But, strong static typing avoids the pain to write many dummy tests, that just deal with context you do not want to deal with.

For example: when developing untyped Python code (e.g. before gradual typing), you had to write many tests just to compensate the lack of typechecking. And when your code evolved, this was a pain to maintain all these tests, but necessary to keep finding stupid bugs.

In my experience, with a very strongly typed language, you also need to write a lot of tests for debugging while developing. But, after your first version is validated, you can often throw away a lot of very fined-grained tests, and only keep more coarse-grained ones. You know that stupid errors later introduced in your code will be found by the typechecker, and expect that your coarse grain ones are sufficient to cover the other ones.

---

<div class="post-metadata">

**Author:** ![ysbaddaden](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/ysbaddaden/32/2252_2.png) [@ysbaddaden](https://forum.crystal-lang.org/u/ysbaddaden)\
**Post date:** [October 16, 2025, 6:09pm UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/8 "2025-10-16T18:09:30Z")

</div>

Related: [Have I hit compiler bugs? - #25 by ysbaddaden](https://forum.crystal-lang.org/t/have-i-hit-compiler-bugs/8303/25)

---

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [October 16, 2025, 8:14pm UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/9 "2025-10-16T20:14:25Z")

</div>

Maybe this is just a misunderstanding about how typing in Crystal works.

Crystal uses strong static typing. But it works as a kind of static duck typing.

The type system does not prevent you from calling a method that is not part of the restricted type’s interface. As long as the _actual_ type implements it, it’s fine.

This concept is pretty unique, and of course it has some downsides. But it’s also really neat. Crystal feels very dynamic because of features like this. And that makes it appealing.

---

<div class="post-metadata">

**Author:** ![kojix2](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/kojix2/32/1583_2.png) [@kojix2](https://forum.crystal-lang.org/u/kojix2)\
**Post date:** [October 17, 2025, 1:10am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/10 "2025-10-17T01:10:26Z")

</div>

The Earth’s ecosystem is vast, and in a zoo you can even see animals like the platypus.

### Objective-C

```auto
#import <Foundation/Foundation.h>

void pay(int x) {
    if (x < 0) {
        [NSObject bug:x]; // not defined
    }
}

int main() {
    @autoreleasepool {
        NSLog(@"Hello!");
        // pay(1000); // ok
        // pay(-1); // uncomment to fail at runtime
    }
    return 0;
}

```

```auto
clang demo.m -framework Foundation -o demo

```

A warning is printed, but it compiles. The crash happens at runtime when calling `pay(-1)`.

### Julia

```auto
function pay(x::Int)
  if x < 0
    bug(x) # not defined
  end
end

println("Hello!")
# pay(1000) # ok
# pay(-1) # uncomment to fail at runtime

```

```auto
juila demo.m

```

* * *

I don’t really know my way around the Programming Language Zoo, so I asked AI to give me a tour.

---

<div class="post-metadata">

**Author:** ![boulme](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/boulme/32/3054_2.png) [@boulme](https://forum.crystal-lang.org/u/boulme)\
**Post date:** [October 17, 2025, 5:14am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/11 "2025-10-17T05:14:10Z")

</div>

> [@straight-shoota](#):
>
> Maybe this is just a misunderstanding about how typing in Crystal works.
> 
> Crystal uses strong static typing. But it works as a kind of static duck typing.

I understand that Crystal type-checker works this way. What I don’t well-understand is that you prefer duck typing to compositional typing. For me, with duck typing, what you win in design/development time, you loose it in debugging time. And I prefer taking time to design/develop than taking time to debug.

But, that is fine. There is no obligation to have the same practices. Diversity of mind is a chance that we should leverage.

Finally, I simply refine my **feature-wish** as the following.

_Could we imagine to have an annotation like “@[TypingContract]” on methods (or classes), such that for methods (or classes) with this annotation, the type-checker ensures that there is no context in which their body does not respect their type restrictions ?_

Hence, on the first example below:

```crystal
@[TypingContract]
def demo(c) : Nil
  a = c.new()
  puts(“demo on #{a.class}”)
  (1..10).each {|x| a.push(x*x)}
  puts(a)
end

```

where implicitly, the type of `c` is ` Object`, then the compiler would complain:

```auto
3 | a = c.new()
^–
Error: undefined method ‘new’ for Object.class

```

On this second example below, with the typing contract:

```crystal
@[TypingContract]
def demo(c : Array(Number).class) : Nil

```

the compiler now would complain:

```auto
5 | (1..10).each {|x| a.push(x*x)}
^
Error: expected argument #1 to ‘Array(Number)#push’ to be Number, not Int32

```

And hopefully, some very smart Lint tool would further help in saying `For example, demo(Array(UInt8)) is ill-typed.`

At, last, hopefully, the following typing contract would be validated by the compiler:

```crystal
@[TypingContract]
def demo(c : Array(Int32 | Int64 | Float64).class) : Nil

```

I believe that such a feature would:

- be backward compatible
- help newbies to learn Crystal
- give a helping tool to debug type-checking errors in large projects
- incrementally lead to incremental compilation: on the use of a class/method with a `@[TypingContract]` annotation, the compiler would know that the analyses done on this component are valid in any context, and can reuse these analyses in the current context without redoing them.

---

<div class="post-metadata">

**Author:** ![straight-shoota](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/straight-shoota/32/36_2.png) [@straight-shoota](https://forum.crystal-lang.org/u/straight-shoota)\
**Post date:** [October 17, 2025, 8:01am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/12 "2025-10-17T08:01:33Z")

</div>

Yeah, I understand this could be useful.  
There is a similar discussion about the return types, in this case of `Enumerable` methods: [Return types of generic interface implementations · Issue #12803 · crystal-lang/crystal · GitHub](https://github.com/crystal-lang/crystal/issues/12803)

* * *

You can easily achieve that effect with an explicit cast to the type restriction.  
This is a bit challenging with the original example because the variability of the generic is hard to describe.

So let’s use this much simpler example:

```cr
def foo(x : Float64 | Int32)
  x << 1
end
 
foo 1

```

`#<<` is only defined on `Int32`, not `Float64`. So calling the `foo(Float64)` signature would not compile. But the only call has a `Int32` argument, and the `Float64` path is never checked.

You can enforce this by explicitly casting the parameter’s type to the type restriction.

```cr
def foo(x : Float64 | Int32)
  x = x.as(Float64 | Int32)
  x << 1 # Error: undefined method '<<' for Float32 (compile-time type is (Float32 | Int32))
end
 
foo 1

```

This could even be abstracted with a macro, so you don’t need to repeat the same type information.

```cr
macro cast_type_restrictions
  {% for arg in @def.args %}
    {% if restriction = arg.restriction %}
      {{ arg.internal_name }} = {{ arg.internal_name}}.as({{ restriction }})
    {% end %}
  {% end %}
end

def foo(x : Float32 | Int32)
  cast_type_restrictions
  x << 1 # Error: undefined method '<<' for Float32 (compile-time type is (Float32 | Int32))
end

foo 1

```

Note: There may be dragons. This mechanism does not work universally because type restrictions have different semantics than casts.  
In the original example, `Array(Float64 | Int32).class` is a valid type restriction for both, but not a valid cast target for neither `Array(Float64).class` nor `Array(Int32).class`.

---

<div class="post-metadata">

**Author:** ![boulme](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/boulme/32/3054_2.png) [@boulme](https://forum.crystal-lang.org/u/boulme)\
**Post date:** [October 17, 2025, 8:57am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/13 "2025-10-17T08:57:11Z")

</div>

> [@straight-shoota](#):
>
> You can enforce this by explicitly casting the parameter’s type to the type restriction.

Great, thank you for this smart trick. This could be workaround until a more robust feature is provided by the compiler.

---

<div class="post-metadata">

**Author:** ![boulme](https://yyz2.discourse-cdn.com/flex036/user_avatar/forum.crystal-lang.org/boulme/32/3054_2.png) [@boulme](https://forum.crystal-lang.org/u/boulme)\
**Post date:** [October 18, 2025, 6:25am UTC](https://forum.crystal-lang.org/t/incremental-static-typechecking/8485/15 "2025-10-18T06:25:26Z")

</div>

Sorry to have submitted this [feature wish](https://github.com/crystal-lang/crystal/issues/16225) without enough discussion. I would love to have feedback from the community on this idea. It seems somehow related to [this proposal](https://github.com/crystal-lang/crystal/issues/4010)
