I was playing around with writing my first roc code, and came across a surprising (to me) crash due to integer overflow. I was able to narrow it down to the following minimal(ish) test case:
app [main] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.10.0/vNe6s9hWzoTZtFmNkvEICPErI9ptji_ySjicO6CkucY.tar.br" }
import pf.Stdout
import pf.Task
printNoOverflow =
rows = 3
rows
- 5
|> List.repeat rows
|> Task.forEach \row ->
row
|> Num.toStr
|> Stdout.line
printAndOverflow = \rows ->
rows
- 5
|> List.repeat rows
|> Task.forEach \row ->
row
|> Num.toStr
|> Stdout.line
main =
# works, printing:
# -2
# -2
# -2
Stdout.line! "working version:"
printNoOverflow!
# crashes with:
# Integer subtraction overflowed!
Stdout.line! "crashing version:"
printAndOverflow 3
What appears to be happening is that List.repeat
takes a U64
, so rows
is being inferred as an U64
in the crashing version, both in the List.repeat rows
expression and also in the rows - 5
expression, with the latter causing the crash.
Whereas, in the non-crashing version, rows
is being inferred as an U64
just in the List.repeat rows
expression, and as a Num *
in the rows - 5
expression.
My question is: is this behavior expected? And/or is there some basic pattern I should be following to avoid the crash?
Hmm... this is definitely strange... rows should indeed be inferred to be U64 because of the List.repeat call. I would expect you to get an overflow in both cases if anything.
What happens if you do:
printAndOverflow = \rows ->
Num.toI64 rows
- 5
|> List.repeat rows
#...
Ah, that works
So I guess the lesson is that the compiler won't necessarily infer for me that any given integer could be negative, and I have to think through each case myself and decide whether it needs to be converted to an IXX
. Does that sound right?
Correct. The other alternative is that you use Num.subChecked
to catch any potential overflows, without type casting to a signed type:
subChecked : Num a, Num a -> Result (Num a) [Overflow]
Oh, probably even better than using a type cast for the subtract operation, you could define the type of your function like:
printAndOverflow : I64 -> Task {} _ # or I32 etc
This would enforce rows as a signed type, and when you try to use it as a U64 you would get a compile error telling you that you that your types conflict. Then typecast into the List.repeat
call.
Locals for numbers are polymorphic I believe
So they can be multiple types
It is a special case
That's at least my guess
So I think row
being polymorphoc leads to row as a U64
and row as a Num a
. The default Num a
being an I64
So it's really the use as a function arg that forces them to unify
Actual roc ir for this:
procedure : `#UserApp.printNoOverflow` [<r>C *self {I64, {}}, C [C [C Str I32, C [C , C , C Str, C , C , C , C ]], C {}]]
procedure = `#UserApp.printNoOverflow` ():
let `#UserApp.40` : I64 = 3i64;
let `#UserApp.rows` : U64 = 3i64;
let `#UserApp.41` : I64 = 5i64;
let `#UserApp.39` : I64 = CallByName `Num.sub` `#UserApp.40` `#UserApp.41`;
let `#UserApp.35` : List I64 = CallByName `List.repeat` `#UserApp.39` `#UserApp.rows`;
let `#UserApp.36` : {} = Struct {};
let `#UserApp.34` : [<r>C *self {I64, {}}, C [C [C Str I32, C [C , C , C Str, C , C , C , C ]], C {}]] = CallByName `pf.Task.forEach` `#UserApp.35` `#UserApp.36`;
ret `#UserApp.34`;
Yeah, two versions of the local.
And failing version where row
has to have single type due to being one arg
procedure : `#UserApp.printAndOverflow` [<r>C *self {U64, {}}, C [C [C Str I32, C [C , C , C Str, C , C , C , C ]], C {}]]
procedure = `#UserApp.printAndOverflow` (`#UserApp.rows`: U64):
let `#UserApp.32` : U64 = 5i64;
let `#UserApp.31` : U64 = CallByName `Num.sub` `#UserApp.rows` `#UserApp.32`;
let `#UserApp.27` : List U64 = CallByName `List.repeat` `#UserApp.31` `#UserApp.rows`;
let `#UserApp.28` : {} = Struct {};
let `#UserApp.26` : [<r>C *self {U64, {}}, C [C [C Str I32, C [C , C , C Str, C , C , C , C ]], C {}]] = CallByName `pf.Task.forEach` `#UserApp.27` `#UserApp.28`;
ret `#UserApp.26`;
@Richard Feldman general question: do we have a better way to expain this to users/clarify expectations. The local acting different from a function arg feel very strange.
It feels like the local has implicit type casting (which it essentially does, but with compile time errors on cast losing precisions or failing)
@Ayaz Hafiz might have an idea on how to explain it better, but my first thought is that the simplest explanation is something like "constants get inlined"
so here:
rows = 3
rows - 5
|> List.repeat rows
rows
is a constant that's known at compile time, so it gets inlined to:
3 - 5
|> List.repeat 3
...which explains the rest of the behavior
\rows ->
is different in that rows
is not a constant, so it doesn't get inlined
That explanation makes perfect sense! This is really good information to know!
Thanks for the explanations, all! :pray:
We have to be careful though, cause it is special for constant numbers specifically, iiuc
currently yes, although we’d like to extend it to other literals
You sure? I thought that was the whole point of the let's not document.
yeah, e.g. if you have a record of number literals, we have all the info necessary at compile time to inline all of that just like we do if all the number literals were in separate defs
Last updated: Jul 06 2025 at 12:14 UTC