Consider this code from the roc-random library:
bounded_u8 : U8, U8 -> Generator(U8)
bounded_u8 = |x, y| bounded_u32_helper(x, y) |> map(U32.to_u8_wrap)
In this case, a U32 is being converted to a U8, but we know it's safe to do so because the value must be between 0 and 255, which is why we use U32.to_u8_wrap instead of U32.to_u8_try.
But the truth is, we never actually want to wrap the U32, we're really using wrap to... unwrap.
I don't mean to single out this library, it's awesome and I just wanted to show a real-life example in production (plus I've written a lot of code like this too). I think it highlights a limitation of the language: it's just too inconvenient to unwrap a value when we know that it's safe to do so, and as a result we often use various tricks that are more convenient but semantically incorrect. The most common are:
U32.to_u8_wrap() # wrap to unwrap
U32.to_u8_try() ?? 0 # use a default value that we don't really intend to use
U32.to_u8_try() ?? { crash "Unreachable" } # correct but painful to write
There are three issues with the first two solutions:
I understand the original motivation for _not_ including an unwrap function, as it is tempting to overuse it and end up with code that doesn't do proper error management. However, I fear that the absence of unwrap may cause worse issues in the long run.
I feel like adding the explicit crash is the best option here if you need the library to model correctness at such a high level.
Is your concern that this is not ergonomic and you would prefer we introduce an .unwrap() as opposed to adding one locally?
I think when we discussed this previously the general consensus was to add a local unwrap helper and use that.
Ah, I knew there must have been a prior discussion, but I couldn't find it. :sweat_smile:
Yes, I feel like the language itself should have an unwrap() method because it makes it unlikely that people will use potentially dangerous workarounds such as wrap or ?? 0, and it makes unwrapping more discoverable: if everyone defines their own unwrap method, they might not name it like that.
I feel like the use case I've shown is a completely legitimate use for unwrap, it shouldn't be discouraged at all.
The use case that should perhaps be discouraged (at least in production) is when it replaces proper error handling.
So I just thought of something: perhaps we could have two variants such as assert_ok() and todo_ok().
This makes unwrapping easy in both use cases, but it's trivial to find all the To-Dos. Moreover, the todo_ok() could be forbidden in release code (although this might encourage people to replace todo_ok with assert_ok, so maybe a warning is preferable).
Here's what it would look like:
bounded_u8 : U8, U8 -> Generator(U8)
bounded_u8 = |x, y| bounded_u32_helper(x, y) |> map(|x| x.to_u8_try().assert_ok())
For the second use-case, where someone is writing a quick script or a first draft of their code:
main! = |args| {
arg1 = args.get(1).todo_ok()
...
}
Previous discussion here
I dont have a strong opinion here, just linking to the discussion I found from searching the history
so I've seen what it looks like to have a convenient "assume no errors and crash if I'm wrong" method, and it is lots more crashes in production :sweat_smile:
I think making crash more convenient is a situation where the cure is astronomically worse than the disease, unfortunately
because it ends up being used in situations where it's both convenient and reachable in production, and the convenience leads to end users experiencing crashes
my point isn't that it's convenient, but rather that in this case inconvenience is much better than crashiness, which in practice is the alternative :smile:
I understand the motivation, really. My point is that there are alternative convenient options that are wrong (e.g., wrap and ?? 0), so we're not really getting rid of the problem, just hiding it under the rug where it might be harder to find. In other words, we're replacing crashes with weird bugs.
I'll grant you that I have no idea how frequently wrap, ?? unused_default, and other "mock" unwraps are actually used. I'll run an agent on a few Roc libraries (old and new) and see how many it can find. It might be a negligible problem.
Yeah I’ve wondered about this. If the “fail fast and crash” approach is undesirable is it a middle ground to have something that crashes in debug mode but uses a default value in release? I agree that the current convenient options probably lead to more silent bugs. Coming from the person who wrote the code in the first example sorry about that lol, in hindsight my gut would be to go with the crash approach I think? Definitely understand not wanting to take down the entire program unnecessarily, I’ve seen some people say they prefer unexpected failures to lead to some kind of graceful no-op ideally, maybe with a crash in debug builds
I wonder if easy to use property tests would reduce the damage of the crashy approach. Looking into it it seemed like it would have to be pretty intertwined with the compiler to handle seeding and reporting failing seeds, might be over my head but I’m definitely interested in it
I asked the agent to search across 9 different libraries, both old and new (roc-random, roc-ray, roc-parser, roc-isodate, roc-ansi, roc-base64, roc-crc32, roc-url, and roc-http). It found:
_wrap that actually serve as unwrap. That's by far the worst offender.match with Err(_) => { crash "..." }.?? { crash "..." }Str.from_utf8_lossy for purely ASCII code (arguably, the more correct code would be from_utf8 followed by unwrap.I understand the point, but I think there's some bias here to be aware of: right now we're seeing mostly instances of places where people should have used a crash but didn't, because it's not super ergonomic to crash.
If it becomes more ergonomic to crash there'll be many cases of crash where non-crashing error handling would be more appropriate. This could be a much bigger problem because these types of cases are not limited to integer conversions, unwrap is convenient everywhere.
I wonder what in those 60+ case's of _wrap used as unwrap is the source of the confidence that the integer conversion ought to be safe. If there's common patterns here, maybe API could be changed to provide safe conversions that account for these?
Yes, that makes sense.
I looked at why the developers of the Roc libraries listed above were confident that they could safely unwrap. Here are the main reasons:
Mathematical guarantees: e.g., in roc-isodate, we compute hours_saturated % 24. We know with 100% certainty that the result fits inside an I8. Also, in roc-crc32, we calculate an array index using .bitwise_and(0xFF), so we're sure to get a number between 0 and 255, and we can safely convert it to an U8.
Precomputed bounds: e.g., in roc-random, the Random.choice function first generates a random number strictly between 0 and list.len() - 1. So when calling list.get(index), we're confident that this cannot fail.
Restricted parsing: in roc-parser, when we parse a specific restricted grammar (like a
1-digit XML version number), we can be confident that the result fits in an U8.
ASCII text: in roc-random, we convert a list of ASCII characters to a Str. Since ASCII text is also valid UTF-8, we know that Str.from_utf8 cannot fail. In Exercism, we call Str.to_utf8, we shuffle the ASCII letters (using various ciphers depending on the exercise), and we go back to Str. There's no way Str.from_utf8 can ever fail since we started with valid UTF-8 and we only changed the ASCII chars.
In my case (roc-crc32) I kind of like that the crash is very loud (rather than a harder to spot .unwrap()):
# index is guaranteed to be 0-255 due to the 0xFF mask, so the lookup
# can never fail. If it does, the algorithm is broken.
table_value =
match crc32_table.get(index.to_u64()) {
Ok(value) => value
Err(_) => {
crash "CRC32 table lookup failed: index ${index.to_str()} out of bounds"
}
}
I felt uncomfortable writing this, because I don't like to crash, but it's the only way out to provide a nice API and the crash really made me consider if it was necessary. I find that unwrap() and expect() in Rust are often used with less rigor, more for convenience.
I think the fact that you can chain on an unwrap() or expect() in Rust makes me more willing to use them. crash feels very definitive and dead-end.
Yes, I'm totally fine with crashes (when they can't happen!), especially with a clear comment and a clear crash message.
I'm more worried by to_u8_wrap or from_lossy_utf8 being used as substitutes for proper error handling.
Aurélien Geron said:
Yes, I'm totally fine with crashes (when they can't happen!), especially with a clear comment and a clear crash message.
Me too, I'm just trying to say that when it's necessary I prefer crash over unwrap (or expect), so I wouldn't be in favor of a general unwrap or expect as Rust offers.
I'm more worried by
to_u8_wraporfrom_lossy_utf8being used as substitutes for proper error handling.
Would this be solved with a specific to_u8_unwrap (or any to_u8_crashy variant)? I would still prefer ?? { crash "..." } but if it's hard to guide users there then perhaps a crashy variant for common transformations makes sense? Actually, writing that out, I'm not even sure I like it :sweat_smile: I hope we can guide users toward crash somehow :shrug:
From what I can tell, the _wrap functions are by far the biggest offenders, so I would argue that we either need to_u8_crashy (love the name, it sounds scary), or we need to discourage using _wrap as a way to unwrap.
Can we guide them to ?? { crash "..." } using warnings?
Not so fast bucko! It seems you're wrapping to unwrap. How about a proper
?? { crash "..." }? I dare ya.
without getting btw I think ?? crash "..." works now (you used to need the { ... } around it, but I think I already landed the fix for that)
unfortunately I think a warning wouldn't work, even if we could reliably detect when it would apply - there are some cases I've found where "out-of-bounds can't happen, so do a wrapping instruction because that's just 1 CPU instruction" optimizes better, and other cases where "out-of-bounds can't happen, so do crash because LLVM uses that information to optimize surrounding things better" optimizes better :sweat_smile:
Aurélien Geron said:
Mathematical guarantees: e.g., in
roc-isodate, we computehours_saturated % 24. We know with 100% certainty that the result fits inside anI8. Also, inroc-crc32, we calculate an array index using.bitwise_and(0xFF), so we're sure to get a number between 0 and 255, and we can safely convert it to anU8.
an idea that seems intuitively like a mistake to me, but which would be cool if somehow it worked: if the compiler can tell (by tracking additional numeric range information behind the scenes - we're already doing some of this for optimizations) that a conversion or bounds check couldn't possibly fail based on the operations being done, then we could let you do Ok(answer) = index.bitwise_and(0xFF).to_u8_try() and have the exhaustiveness checker permit it because the compiler knows the Err branch would be unreachable
Oh that would be nifty!
one reason it seems intuitively like a mistake is that it could be brittle and wouldn't always be possible to apply, but also I can imagine situations where crossing package boundaries with that behind-the-scenes range information would lead to things being breaking changes due to exhaustiveness changes even though all you changed was the internal implementation of a package :sweat_smile:
there might be a way to make that ok though, I'm not sure
I think the performance would be ok if we moved that from the optimization stage to earlier in the pipeline
Yeah, it would need to work consistently, or else it might be frustrating. Maybe not worth it.
I guess a reason in favor of it might be that if you intuitively reach for Ok(answer) = ... # call a _try method and it gives you an exhaustiveness error, then from that point adding Err(_) => crash "..." is kinda the shortest distance
it might be worth trying just on the grounds that we're pre-0.1.0 and if it doesn't work out, now's the easiest time to back out the experiment :smile:
Richard Feldman said:
Ok(answer) = index.bitwise_and(0xFF).to_u8_try()and have the exhaustiveness checker permit it because the compiler knows theErrbranch would be unreachable
Is this kind of thing really any different from using _wrap though? Addressing two of Aurélien's first three points
index : U32 grows outside the 0xFF mask bounds, the behaviour is incorrect, even if the "unwrapping" part is safe in the eyes of the compiler. The compiler proving that it is safe to unwrap after masking is imo no different to using _wrap(). The problem is the domain before the mask, no?_wrap() or _try() ?? ... etcOf course if you never needed to write the bitwise_and bit because of other implicitly tracked information it would be different :smile:
Edit - I had the feeling I'd missed something. I guess these are cases where you do already do the extra step and it doesn't correspond to wrapping along clean integer size boundaries, like hours_saturated % 24, but nevertheless provably fit within a size.
I looked into this a bit and I think it would have more downsides than upsides, nm :smile:
Maybe compile-time evaluation of constants could be used to generate safe mod_by_X functions?
Ok(mod_by_24) = U32.mod_by_I8(24)
expect 2.I8 == mod_by_24(26.U32)
Dunno, seems complicated though.
Wait, couldn't mod_by have a signature mod_by : U32, num -> num? (example for U32, but the same could exist for other integer types)
in
roc-isodate, we computehours_saturated % 24. We know with 100% certainty that the result fits inside anI8.
hours_saturated.mod_by(24.I8)
in
roc-crc32, we calculate an array index using.bitwise_and(0xFF), so we're sure to get a number between 0 and 255, and we can safely convert it to anU8.
bits.mod_by(U8.highest)
:point_up: makes me realize it doesn't work. You'd have to mod by 256 which is just out of range of the U8. That's such a shame.
I was just testing this too. I think it's possible though - you can change it to be a mask and check that the mask fits in U8.
Or it could be addressed with naming: instead of replacing mod_by this could be a separate function called wrap_at. Maybe it could replace to_X_wrap functions.
Jasper Woudenberg said:
Wait, couldn't
mod_byhave a signaturemod_by : U32, num -> num? (example forU32, but the same could exist for other integer types)in
roc-isodate, we computehours_saturated % 24. We know with 100% certainty that the result fits inside anI8.hours_saturated.mod_by(24.I8)in
roc-crc32, we calculate an array index using.bitwise_and(0xFF), so we're sure to get a number between 0 and 255, and we can safely convert it to anU8.bits.mod_by(U8.highest):point_up: makes me realize it doesn't work. You'd have to mod by 256 which is just out of range of the
U8. That's such a shame.
it could return a Try which you could exhaustively match when calling it at compile time
there may be some potential "parse, don't validate" opportunities for some of these
Richard Feldman said:
it could return a
Trywhich you could exhaustively match when calling it at compile time
But that would require knowing both operands of the mod_by at compile-time, right? Would be nice if you could pass the number being modulo'd in at runtime.
If a pure function is modeled as a graph of computation, I suppose any part of the graph that is static at compilation time could be reduced? That would be exciting, but probably way too ambitious/complex?
Last updated: Aug 12 2026 at 12:35 UTC