Stream: ideas

Topic: Wrap or unwrap?


view this post on Zulip Aurélien Geron (Aug 09 2026 at 00:50):

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:

  1. They can be confusing because they seem to be doing something that they're not. The first one never actually wraps, and the second never actually uses the default value.
  2. If a bug is introduced in the future, the "unreachable" branch may be reached silently and cause problems downstream that may be hard to debug.
  3. They are not discoverable: there's no way to search in the code to find all the places where we're effectively unwrapping a value.

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.

view this post on Zulip Luke Boswell (Aug 09 2026 at 01:17):

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.

view this post on Zulip Aurélien Geron (Aug 09 2026 at 01:32):

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).

view this post on Zulip Aurélien Geron (Aug 09 2026 at 01:36):

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()
    ...
}

view this post on Zulip Luke Boswell (Aug 09 2026 at 01:42):

Previous discussion here #beginners > result unwrap? @ 💬

view this post on Zulip Luke Boswell (Aug 09 2026 at 01:44):

I dont have a strong opinion here, just linking to the discussion I found from searching the history

view this post on Zulip Richard Feldman (Aug 09 2026 at 01:53):

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:

view this post on Zulip Richard Feldman (Aug 09 2026 at 01:54):

I think making crash more convenient is a situation where the cure is astronomically worse than the disease, unfortunately

view this post on Zulip Richard Feldman (Aug 09 2026 at 01:55):

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

view this post on Zulip Richard Feldman (Aug 09 2026 at 02:08):

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:

view this post on Zulip Aurélien Geron (Aug 09 2026 at 02:47):

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.

view this post on Zulip Aurélien Geron (Aug 09 2026 at 03:11):

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.

view this post on Zulip Austin Clements (Aug 09 2026 at 04:02):

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

view this post on Zulip Austin Clements (Aug 09 2026 at 04:14):

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

view this post on Zulip Aurélien Geron (Aug 09 2026 at 06:32):

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:

view this post on Zulip Jasper Woudenberg (Aug 09 2026 at 06:57):

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?

view this post on Zulip Aurélien Geron (Aug 09 2026 at 07:29):

Yes, that makes sense.

view this post on Zulip Aurélien Geron (Aug 09 2026 at 09:40):

I looked at why the developers of the Roc libraries listed above were confident that they could safely unwrap. Here are the main reasons:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

view this post on Zulip Niclas Ahden (Aug 09 2026 at 09:59):

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.

view this post on Zulip Niclas Ahden (Aug 09 2026 at 10:05):

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.

view this post on Zulip Aurélien Geron (Aug 09 2026 at 10:05):

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.

view this post on Zulip Niclas Ahden (Aug 09 2026 at 10:18):

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_wrap or from_lossy_utf8 being 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:

view this post on Zulip Aurélien Geron (Aug 09 2026 at 10:25):

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.

view this post on Zulip Niclas Ahden (Aug 09 2026 at 10:37):

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.

view this post on Zulip Richard Feldman (Aug 09 2026 at 11:23):

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)

view this post on Zulip Richard Feldman (Aug 09 2026 at 11:24):

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:

view this post on Zulip Richard Feldman (Aug 09 2026 at 11:29):

Aurélien Geron said:

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.

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

view this post on Zulip Aurélien Geron (Aug 09 2026 at 11:31):

Oh that would be nifty!

view this post on Zulip Richard Feldman (Aug 09 2026 at 11:32):

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:

view this post on Zulip Richard Feldman (Aug 09 2026 at 11:32):

there might be a way to make that ok though, I'm not sure

view this post on Zulip Richard Feldman (Aug 09 2026 at 11:33):

I think the performance would be ok if we moved that from the optimization stage to earlier in the pipeline

view this post on Zulip Aurélien Geron (Aug 09 2026 at 11:33):

Yeah, it would need to work consistently, or else it might be frustrating. Maybe not worth it.

view this post on Zulip Richard Feldman (Aug 09 2026 at 11:33):

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

view this post on Zulip Richard Feldman (Aug 09 2026 at 11:35):

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:

view this post on Zulip Jonathan (Aug 09 2026 at 11:59):

Richard Feldman said:

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

Is this kind of thing really any different from using _wrap though? Addressing two of Aurélien's first three points

  1. If 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?
  2. I don't feel it's any more discoverable to check for cases of mask then pattern match on try, than it is to see _wrap() or _try() ?? ... etc

view this post on Zulip Jonathan (Aug 09 2026 at 12:07):

Of 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.

view this post on Zulip Richard Feldman (Aug 09 2026 at 12:41):

I looked into this a bit and I think it would have more downsides than upsides, nm :smile:

view this post on Zulip Jasper Woudenberg (Aug 09 2026 at 13:00):

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.

view this post on Zulip Jasper Woudenberg (Aug 09 2026 at 13:03):

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 compute hours_saturated % 24. We know with 100% certainty that the result fits inside an I8.

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 an U8.

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.

view this post on Zulip Jonathan (Aug 09 2026 at 13:13):

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.

view this post on Zulip Jasper Woudenberg (Aug 09 2026 at 13:31):

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.

view this post on Zulip Richard Feldman (Aug 09 2026 at 14:45):

Jasper Woudenberg said:

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 compute hours_saturated % 24. We know with 100% certainty that the result fits inside an I8.

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 an U8.

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

view this post on Zulip Richard Feldman (Aug 09 2026 at 15:10):

there may be some potential "parse, don't validate" opportunities for some of these

view this post on Zulip Jasper Woudenberg (Aug 09 2026 at 15:18):

Richard Feldman said:

it could return a Try which 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.

view this post on Zulip Matthieu Pizenberg (Aug 09 2026 at 23:03):

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