I've just created a PR that adds Dec.from_attos and Dec.to_attos: https://github.com/roc-lang/roc/pull/10573. Making a thread here since it's a change to the public API that perhaps warrants discussion.
As implemented, Dec is a wrapper for an I128 that represents the Dec's value scaled by a factor of 10^18. For example, if the value in the Dec is 0.0123, that would be represented as the I128 12300000000000000 (1.23 * 10^16).
In some contexts (such as JSON parsing), it would be convenient to be able to construct a Dec by first constructing this I128 value, and then converting it to a Dec. In that case the conversion is trivial, because the bits are already in the shape we want. That I128 value is literally how the Dec is stored.
At present though, there's no way to directly convert such an I128 to a Dec. Note for example that I128.to_dec_try isn't what we want, because that converts an un-scaled integer to a Dec, and the value of the I128 might be higher than Dec.highest (which is why it returns a Try). You also couldn't just divide your I128 by 10^18 and then convert it to a Dec and then multiply it back by 10^18, because you'd lose precision on the integer division.
So, to give developers the option of building the I128 first and then converting it to a Dec, I propose to add Dec.from_attos and Dec.to_attos. Note that "atto" is the SI prefix for 10^-18. So the idea is that the I128 already just directly holds the value of the number of "attos" in the value that a Dec represents. And this just gives developers the option of working with that integer number of attos.
To go into more detail about why you'd want to build the I128 first and then convert it to a Dec, rather than just operating on a Dec the whole time, there are two primary reasons I'm aware of:
10 and then adding to it in a loop, if you're operating on Decs then each of those * 10 operations is a mul_and_decimalize that has to do division internally (because it's multiplying two scaled-by-10^18 values, giving you a scaled-by-10^36 value, which you then need to divide back by 10^18 to get the right scale). Whereas if you had been operating on an I128 that you convert to Dec once at the end, there's no division needed.I128 in the first place. For example, if working with cryptocurrency, Ethereum stores its value as an integer number of wei which are defined as a 10^-18th of an Ether. So if you received an integer number of wei, you could convert that to a Dec amount of Ether by interpreting that integer value directly as the internal I128 of the Dec, with no math needed.pretty clever! I don't think the name attos is very discoverable, although it does make great sense when you explain it. That said, I can't think of a better name. :sweat_smile:
like to_underlying_i128 or something could arguably be more self-descriptive, but then we're kinda committing to this exact representation forever. Not that I'd expect it to ever change, but it is nice that if you want the attos and the underlying representation changes, we can presumably make that a nonbreaking change that affects perf but not correctness
I don't think the name
attosis very discoverable
Yeah, my Claude first proposed Dec.from_scaled, but I liked from_attos better because it just more directly tells you exactly what's going on (you don't have to go look up what the scale factor is; though of course you may have to look up what an "atto" is :stuck_out_tongue:).
Another option we considered was from_bits, matching the float conversions, but I didn't like that either because you're not passing in an opaque set of bits, it's an I128 that has a clear semantic meaning.
Good point about being able to change the representation w/o having to change the function name. Hadn't thought of that.
One thing I was wondering is if there's a way to make it more discoverable at least in the docs via a pun reference. Like I thought I remembered that there was an idea to put an entry for something like List.reduce in there that just says "see: List.fold" or something like that. So we could do the same thing with Dec.to_scaled or whatever. Though honestly I'm not sure what the more discoverable name would be.
I hadn't thought of that to_underlying_i128 option. Maybe that's the clearest, but as you say it has that committing-to-the-representation problem. Though I guess if we just put that in the docs and not the code it's okay?
Oh, for discoverability my Claude suggests you could change the doc comment on I128.to_dec_try to point out the alternative:
## Convert an [I128] to a [Dec], returning `Err(OutOfRange)` if the
## integer value does not fit in [Dec]'s fixed-point range. This reads the
## integer as a count of ones; to read one as a count of attos (10^-18),
## see [Dec.from_attos].
I might tweak the wording that it came up with, but the idea seems not crazy.
that seems reasonable! :thumbs_up:
Updated the PR with doc comments linking to the _attos conversions from I128.to_dec_try, Dec.to_i128_wrap, and Dec.to_i128_try.
Though when looking at those functions I noticed that Dec.to_i128_wrap and Dec.to_i128_try are kind of weird. It looks like we have both just to match the pattern of the other Dec-to-int conversions, which could overflow since the integer part of a Dec is potentially larger than e.g. I64. But the integer part of a Dec is smaller than I128, so "wrapping" doesn't mean anything here, and the _try version always returns an Ok.
So should we just replace Dec.to_i128_wrap and Dec.to_i128_try with a single Dec.to_i128 whose signature is Dec -> I128 with no Try wrapper?
yeah that sounds better :+1:
Okay, so I went to implement that change to collapse Dec.to_i128_wrap and Dec.to_i128_try into a single Dec.to_i128, and came across a number of bugs with the Dec-to-int conversions in the existing code.
Here's a PR to fix them: https://github.com/roc-lang/roc/pull/10664
(Planning to implement the collapse in a follow-up PR. I've got a draft, but wanted to get these bug fixes in first.)
Oh, looks like you might have separately fixed these bugs via https://github.com/roc-lang/roc/pull/10681
Going through my PR to see if any of my changes are still worth keeping...
Extracted this PR out of my now redundant #10664. Adds some tests, makes the LLVM backend fail the build on unhandled ops rather than crashing at runtime, and adds a compile-time check for little-endianness in the conversion wrappers.
https://github.com/roc-lang/roc/pull/10734
And then I have two draft PRs building on this work:
Dec.to_i128_wrap and Dec.to_i128_try with Dec.to_i128, as we discussed above.llvm-conversion-op-explicit-dispatch project.Last updated: Aug 12 2026 at 12:35 UTC