Hello everyone, first time posting here and first time using Zulip (actually)!
I have gone through the tutorial section on numbers and I've found you can be quite specific about the numbers you're using in your Roc program (u8
, u16
, i32
and so on), but you can also use Num
, Int
and Frac
to be open to any kind of numbers, integers and fractions respectively.
From a high level program perspective, ignoring performance, using Int
and Frac
when we need to differentiate between integers and fractions can be done through all the program's code without ever mentioning specific number types like u8
, u16
, i32
and so on and without the need to think about overflows?
Welcome @Kristian Notari!
I'm not the expert on this but I think the compiler does end up creating specific variants of functions. So when the user sees a function that uses Int
the compiler will end up constraining it to say i64
if it detects that is the type that will end up as input or output. So overflows can definitely happen. I believe this is a lot faster than trying to detect and patch overflows on the fly by dynamically moving something to a bigger type, which is why this choice was made.
also, overflow panics in roc with the default infix operators
so overflows won't ever happen silently, unless you explicitly opt into that
Ok so Roc's take on this is somehow more low-level than something else where, for example, by default the Num type is treated as the widest possibile implementation of a numeric value, let's say i64
or f64
.
Cause I'd like to take advantage of runtime speed that Roc is giving while still offering a simple and intuitive functional like writing and modeling experience. I'd like not to base my thoughts on "will this crash at runtime due to some overflow happening" but rather on "if it compiles it works".
I don't see how I should be peacefully writing programs with these premises, from an abstract high level perspective I mean. I'd like to know more on how should I approach this topic when writing programs.
Let's say I have my custom add function (for some reason):
add : Num a, Num a -> Num a
Should I consider it safe to add two i8 values? Like what happens if the only point in my code where I use this function is to calculate:
result = add 127i8 127i8
Should I expect the result to be a crash with overflow due to the usage of i8 or should it be 127 + 127 = 254?
Oh I've just seen Num is typed, so I guess that when using i8 values they overflow since i8 addition is used
yes, exactly
The actual signature of add
will be
add : Num a, Num a -> Num a
which we can write more explicitly as (this is not actual roc syntax)
add : forall a. Num a, Num a -> Num a
So now when you go to apply this function to an actual value, the type checker will make the types line up. The I8
type is an alias for Num Signed8
, so the actual add you call has type
add : Num Signed8, Num Signed8 -> Num Signed8
and that call will overflow with the inputs you picked
now, what happens depends on your implementation of add here
with add = Num.add
, it overflows
Yeah I was just pretending to wrap the Num.add implementation basically, so I expect an overflow, so a runtime crash with no compile-time warning, right?
but we also have addSaturated
or addWrap
yes
we've made this choice quite deliberately. Overflow of an i64
is rare, but if it happens you don't want that to happen silently, even in production
Cause I'd like to take advantage of runtime speed that Roc is giving while still offering a simple and intuitive functional like writing and modeling experience
There is a direct tradeoff between speed and high-level convenience. No language or system can give you maximum speed and maximum convenience at the same time.
The Num package has functions that cover various options
Num.addChecked
Num.addSaturated
Num.add
or +
Num.addWrap
(this is what a hardware adder circuit does, so it is the fastest)also worth noting that numbers in mathematics are infinite, but computers have finite resources. So there does not (and can never even theoretically) exist a computer that can offer a programming experience where you never think about overflow and it always works out fine.
so it's a question of where you decide to draw that line. Python for example has "arbitrary ints" - which means "overflow happens when you run out of memory"
Another option could be to write your own prettySafeAdd: Num a, Num a -> i128
.
yeah I've actually been thinking that defaulting to I128 would be a better default
really, how does one overflow an i64 by accident in a way that it would not with an i128
seems like it would just provide more space to hide bugs
Thanks everyone.
I'm not concerned by speed, rather by high level abstraction and "not have to think about low level stuff under the hood". With this system, if I expose some API that uses two numeric inputs in a potentially unsafe way (where unsafe mean potential overflows), I can't really be sure the user it's using the API correctly cause if I use Num a
as my types and I'm not informing them to the risks (within the type level, so no compile-time errors on their part), if they use i8
values they are much more likely to encounter overflow issues.
So I'm basically leaving that "risk" to the user without letting him know, cause there's no contract/types involved, just the Roc docs saying it's potentially unsafe to do numeric operations, right?
if your user explicitly picks I8
, which is not the default anywhere, and then use the default operators then it is likely they run into overflows and hence crashes, yes
and actually, very likely, so will your library code. Being generic over numbers breaks down very quickly
I mean, if my exposed API is "doSomethingWithNumbers" the user doesn't know addition and potential overflows are a thing, it can't be certain
it is nice for the standard library api, but your own code should usually quickly just pick one representation
correct. On the other hand, there is no alternative. You could be using wrapping addition and that may not be what your user wants/expects either
Apart from numeric operations, are there any other "common enough" cases where potential runtime crashes can happen that are not reflected into the type system nor compile time errors?
(I'm not speaking about List being instantiated with more than the process memory capacity, but more common and probable things)
By the way, in order to have 100% safe exposed API (except for some more rare 128+ overflows) I should always accept a signed/unsigned number of the most wide numeric type and so force the user to convert their numbers to my accepted (the widest) numeric type (in Roc being I/U 128
and Dec
).
And to be the most user friendly and safe possible, I should accept whatever the user is passing to me and then convert them instantly in their widest counterparts and use them internally.
(I mean, ignoring performance/memory)
Is using something like “addChecked”, which will return a result and an Err on overflow, an option for you?
I understand the ergonomics suffer a bit because your users will get a Result rather than a number directly, but it will be a safe API.
I'm not concerned about returning a Result. It's the opposite in fact. If I can reflect the risks at the type level, I'm more than glad to do so and I'm likely going to do so if I ever have to write something like that down (whenever reasonable to do so).
My concerns are that is not going to be that common to do numeric operations with results, as a community, since the language also have "unsafe" counterparts ready to use.
Personally, I was expecting a "safe" first approach on this by Roc, like defaulting to 64 or something like that when being generic over numeric types as said here, just to exclude some minor but more frequent bugs with lower than 64 bits numbers
We do default to 64bit types when none is picked. Which means unless a user chooses otherwise it is relatively safe.
If you want safety, there are simply some things you can't be generic over (without extra checks). By choosing Num a
as a type in a function signature, you are accepting the risk that a user might pass in an I8
or a U128
. In many cases, there is likely no safe and fast way to deal with both of those types.
Compared to most systems languages I would say that crashing on over or underflow by default is extremely safe because it stops the application before something goes wrong.
That being said, it obviously is less safe than something like python for numerics. I guess you could try and argue JavaScript as well, but JS is always floating point numerics are another can of worms.
If we wanted to enable safer Roc code (from the numerics perspective), we could add a flag to enforce checked math. I personally find that way too tedious because your code becomes riddle with results, but it would enable generic and safe code.
Yeah I was not criticizing. It was more an opportunity to know the reasons why. I assumed by default the most safe way of doing things was the go to for Roc and I was excepting not to incur in “unsafe runtime crashes” explanations directly in the tutorial. But I guess those number representations decisions also help with performance which is one of the goals
Are there other untyped run-time issues besides running out of memory?
I wonder if we should have a CheckedNum a
type or something similar that removes default math operators. Forces the user to directly call addWrap
, addChecked
or addSaturated
. That at least would give a very clear opt into safety. Of course this could easily be a userland library, though slightly less convenient as such.
Other cases for panic: division and modulus by zero, i think. Maybe some float related stuff, but I'm not sure in the end what all has been setup with floats. That said, we default to a decimal fixed point that won't hit as many issues as floats
From a community perspective, the more alternatives you make the less clear intents and code become. Today is checked math, tomorrow is an alternative formatter, two months from now is a “legacy-something” compiler flag, and so on
That’s why I was expecting checked math by default, but that could be seen as too rigid from a practical perspective
(Checked math or at least checked math for sub wide number types like i8 and similar)
We originally had some checked math by default. Like with division by zero. In most cases, is was just an inconvenience and user would end up writing: Result.withDefault (a / b) 0 # b should never be zero
. It just appears all over certain code.
(Checked math or at least checked math for sub wide number types like i8 and similar)
I feel like that would be very confusing to users. Specifically because it would be inconsistent based on the type. Also, You wouldn't be able to use Num a
anymore. It would need to do different things based on the version of Num a
. So you would still need something like SubWideNum a
and WideNum a
to support something like that.
We originally had some checked math by default. Like with division by zero. In most cases, is was just an inconvenience and user would end up writing:
Yeah, I don't think we could call roc beginner-friendly if we decided to do that.
Yeah it's suboptimal whatever way you choose I guess. Picking the "right" suboptimal for is not easy task, but I'm interested in following what would end up being the "common way of doing things".
Brendan Hansknecht said:
We originally had some checked math by default. Like with division by zero. In most cases, is was just an inconvenience and user would end up writing:
Result.withDefault (a / b) 0 # b should never be zero
. It just appears all over certain code.
From my personal perspective, this is the most "correct" way of doing such things. As in any other domain where things can fail, here you have it described in the type system for you to help reason about what you're doing. The number of times you encounter that doesn't matter. Moreover, the "beginner-friendliness" of this approach I guess is debatable. Cause nowadays more and more people are thinking about their programs in a more safe and typed way. I'm no saying everyone is expecting checked divisions, but it's not as strange as it was 10 years ago. Being an "early" adopter of "everything checked" by default sounds great to me. But that's purely personal
The issue is that you end up opting into a garbage value. That is not safe, but that is the default of what most people would write.
So it was safer to panic unless someone truly wants to handle the check
But yeah, I can see the tradeoff. I think it just makes numerics pretty impractical to use, but that is just an opinion
I am scanning the code trying to collect possible panics (we should make a doc with a list somewhere if it doesn't already exist):
roc run
. Your code would then hit a runtime error if it runs part of the code with a bug. For example a when
statement that does not match on all possibilities.I think that is a correct list, but I easily could have missed something. So really just numerics.
Yeah I got your point and I agree with the impractical usage. If the list is limited as you're saying I guess it's a no brainer and it's good as it is, the tradeoff is more than acceptable
Still, for math operations the alternative checked functions are there right?
Yep
all math functions that could panic have alternatives
So I guess "all checked math functions" label on packages is the new "no unsafe" of Rust libraries then :D
Haha, I guess. This is definitely something to re-evaluate as Roc grows. Hard to say what the right option is.
For example, it may be that libraries should be forced to use checked math, but applications can use panicking math. I think it is much more common in applications to just ignore cases you don't expect to happen. Helps quickly develop and iterate. With libraries, maybe we want more guarantees in general.
The mix of high and low level use cases is always a balance in Roc.
What if instead of Result.withDefault (a / b) 0
you'd instead write something like
lotsOfMath : UnsafeNum I64
lotsOfMath = a / b + 5 * c
match lotsOfMath with
Ok ok -> { data & value : ok }
Err _ -> data
In other words, we are doing math as if it was a transaction that we then check at the end to see if it worked out (and we can't forget to check because the data structure we are assigning it to has Num I64, not UnsafeNum I64)
Interesting :)
Which is basically leveraging chainable computations capabilities of mathematical functions in an upper level “Expression : Result Num *”
That would be like chaining tasks or results, so going from values to expressions, staying there as much as you can then come back to either a value or an error
But it has the same drawbacks as checked operations when you only have to do small math operations in different parts of your code
A lot of unwrapping (and potentially wrapping)
Which I’m all for, btw 😅
That is a much more usable idea. Does cost in storage space though (every number type will take up 2x the space in a List). Also, without a smart compiler to skip operations could spend a lot of time just passing around an error result.
Also would be less efficient performance wise, but only minorly so.
We actually discussed doing something similar for floats at one point. Though in that case the discussion was floats are for speed to not have issues with NaN and etc, just use Dec.
So different context for sure.
Last updated: Jul 06 2025 at 12:14 UTC