Is there a generic type for unsigned integers, as in, a type that the only restriction on the number is that it is unsigned and does not limit the size or the underlying representation of the number? I would like to write functions that work on any unsigned integer to only choose its size at the edge of the app.
Is that what an Int * is?
Currently no: https://github.com/roc-lang/roc/blob/main/crates/compiler/builtins/roc/Num.roc#L421
As you can see in the definitions for I128
, U8
, etc. the size & signedness are currently entangled
However, I imagine that could change
Could you do something like [U16, U32, U64, ...]?
@Luke Boswell you could use tags to indicate what type of data you're intending to be passing around, but it wouldn't enforce it
since the numerical system currently only supports enforcing either integerness or 64-bit-unsigned-integer-ness, but nothing in-between
@Chris Duncan in other words, you can definitely write those functions today, but you can't prevent yourself from using them on signed integers
well, actually, nevermind, @Luke Boswell makes a good point - you could restrict them to operate on the exact complete set of currently-defined unsigned integer types
Yeah, re what Luke suggested, you could do something like
UnsignedInt : [U8 U8, U16 U16, ...]
addUnsigned : UnsignedInt, UnsignedInt -> UnsignedInt
but this can be kind of annoying , since you'll need to enumerate all cases in a when
expression
UnsignedFriend : [Smol U8, Small U16, Medium U32, Big U64, Beeg U128]
addUnsigned : UnsignedFriend, UnsignedFriend -> UnsignedFriend
addUnsigned = \a, b ->
when a is
Smol aa ->
when b is
Smol bb ->
Smol (aa + bb)
Small bb ->
Small ((Num.toU16 aa) + bb)
Medium bb ->
oh no
...
Small aa ->
oh no
...
Currently this would require 25 when
cases, which means 40+ lines of repetitive code
The practical solutions to this are:
a) in your project, discourage (but don't prevent) use with signed integers by naming it something like fooUnsigned
b) in the Roc builtins, disentangle signedness from size for integer types for everyone, which doesn't seem crazy
However, if short-term safety is your goal, then enjoy a Beeg
function :laughing:
Yeah, I guess we could add this to Num.roc
so that Integer range
is defined as Integer range := [Signed range, Unsigned range]
. And then you get Unsigned range : Integer (Unsigned range)
and likewise for Signed
At runtime there would be no extra cost here, just possibly a minor cost to typechecking
@Ayaz Hafiz Would the definition instead be something like Integer signedness range := { signedness, range }
so that we can define integer types like I128 : Num (Integer Signed 128)
? I'm unfamiliar with opaque types, but I don't know how we'd define I128
with the Integer
definition you gave above.
Hmm, maybe I128 : Num (Integer (Signed 128))
, but something feels wrong about (Signed 128)
mapping to range
...
either way works, those two definitions are actually identical in terms of what they can express :sweat_smile:
there are probably other ways too
Oh, cool... but how do we not need signedness
as a type variable?
well presumably it would only take on two forms, Signed or Unsigned. That’s why i explicitly enumerated them
“range” could be thought of as “bitWidth” here
Sorry if I'm being thick here, but wouldn't
Integer range := [Signed range, Unsigned range]
I128 : Num (Integer (Signed 128))
mean that I128
is "represented"(?) as Num (Signed (Signed 128))
? And how would it know that first Signed
part?
as opposed to
Integer signedness range := { signedness, range }
I128 : Num (Integer Signed 128)
meaning that I128
is represented as Num { signedness: Signed, range: 128 }
?
Maybe the generalization of my question is:
How can you ever use a tag union as an underlying representation of an opaque type when the tags represent an externally-pickable behavior, since you can't pick the tag via type variables?
(pardon my lack of vocabulary around opaque types, I'm sure "pick" and "represent" aren't ideal words here)
(and I'm not asking just to be pedantic - I went to implement this change and got stuck)
I wasn't thinking of a signedness
type variable, instead to enumerate signedness explicitly - sorry, I know I glossed over that in your description
Concretely
<oops>
oh oops
one sec
sorry, you're right, I got too caught up in the value level. my bad, that was really a huge oversight on my part. you would have to have a type variable for the sign
Phew, I feel like I just connected a bunch of static-typing neurons in my brain :big_smile:
No yeah you're totally right. Sorry again
All good! It was a helpful exploration for me :) hopefully others, too
I'm diving back into implementation now, and enjoying the nuance of Nat
:stuck_out_tongue:
My implementation so far, what do y'all think? :smiley:
Num range := range
Integer signedness bits := { signedness, bits }
Fraction pointSystem bits := { pointSystem, bits }
Int signedness bits : Num (Integer signedness bits)
Frac pointSystem bits : Num (Fraction pointSystem bits)
I8 : Int Signed Static8Bits
I16 : Int Signed Static16Bits
I32 : Int Signed Static32Bits
I64 : Int Signed Static64Bits
I128 : Int Signed Static128Bits
U8 : Int Unsigned Static8Bits
U16 : Int Unsigned Static16Bits
U32 : Int Unsigned Static32Bits
U64 : Int Unsigned Static64Bits
U128 : Int Unsigned Static128Bits
Nat : Int Unsigned Dynamic32Or64BitsPerSystem
Signed := []
Unsigned := []
Static8Bits := []
Static16Bits := []
Static32Bits := []
Static64Bits := []
Static128Bits := []
Dynamic32Or64BitsPerSystem := []
F32 : Frac FloatingPoint Static32Bits
F64 : Frac FloatingPoint Static64Bits
Dec : Frac FixedPoint Static128Bits
FixedPoint := []
FloatingPoint := []
I wish I saw a way to enable both generic integers and generic 32-bit numbers... because it's so close now... but that doesn't seem syntactically possible to cut generically across both of those dimensions. Oh well, handling both 32-bit integers and 32-bit fractions probably doesn't have many use cases... right?
I'm curious what the motivating use case is! I thought about having this as a distinction back in 2018 but concluded it wouldn't be worth the added type complexity and (probably very minor) compile time increase :big_smile:
I don't think you can add it onto the existing system, but it's definitely possible (at nontrivial cost) to make Num
support this while still supporting all the use cases it currently does
("it" & "this" meaning 32-bit generics?)
Yes, I'm also interested in the use cases for signedness/bit-depth generics! What's new since 2018?
@Chris Duncan what's your motivation for this? (mine is just "because it seems right")
my threshold for making Num
more complex is way higher than "seems right" :laughing:
my original motivating use case for signedness was wanting Num.neg
to only accept signed numbers
because with unsigned ones you either give it exactly 0
or else it's going to panic
Well, really how much more complex is Num (Integer Signed Static8Bits)
than Num (Integer Signed8)
? Is it a matter of character count?
Personally it feels more explanatory, which counts for something, even lowering complexity by making it less magical
However, compile times matter.
I'll give a couple examples I've run into:
(To be fair to the downsides of verbosity, it would be pretty jarring to type List.len [1, 2, 3]
into the REPL and see 3 : Num (Integer Unsigned Dynamic32Or64BitsPerSystem)
:laughter_tears: ...it's not wrong, though)
@Ayaz Hafiz You beat me to it :laughter_tears: I'm also doing Advent of Code, and I'm encountering the same want of having functions that operate over natural numbers and expressing that restriction in the types.
It's worth mentioning Nat
already exists, and it can already service the use cases mentioned so far
(unless you're working with big numbers on a 32-bit system)
Let's continue discussing if this is sufficiently motivated/justified, but here's a visualization of what the builtins changes might entail: https://github.com/roc-lang/roc/pull/4268/files (and it's missing how many other downstream files will need to change)
I remember from my earlier exploration (it's been a few years, so not sure exactly what I had written down, or where) that it's doable with Int
still having one type parameter
and then having separate Signed *
and Unsigned *
it was something to do with Int
's type param being a record I think
so it was something like Signed a : Int { signedness : [Signed], other : a }
where other
is whatever other info you want in there (e.g. number of bits)
@JanCVanB, I am using Nat
precisely because it's the most generic of the unsigned integers.
@Richard Feldman oh interesting, does reducing the quantity of type parameters inherently reduce complexity to either developers or the compiler?
I think it does for developers - like consider the type of https://www.roc-lang.org/builtins/Num#shiftLeftBy
Int a, U8 -> Int a
vs
Int a b, U8 -> Int a b
it's not the end of the world, but it's definitely more to think about for no real benefit in the common case
the type parameter in the first type communicates "whatever type of integer you pass in, that's the type of integer you'll get back" - just like List a
the second one communicates the same information, but in a way that requires strictly more explanation
:100: I forgot that a majority of exposures to these type signature will be with placeholders like a
and *
it also eats up a whole letter in complex signatures
yeah I think it's valuable to have Num
, Int
, and Frac
all have one type parameter, no matter how deep the hierarchy goes beneath them
Totally - and it's trivial to adapt my implementation above to do that.
I don't like this idea but you could do this with abilities rather than extra type parameter
Last updated: Jul 26 2025 at 12:14 UTC