This is just a random thought and may not make sense at all, but I think with static dispatch and with some binary functions being possible to override with static dispatch, num is no longer needed as a true type.
Instead, each numeric type gets its own module and implements the numeric functions it needs. This means floats can avoid eq and hash. Type checking is still the same cause fundamentally seeing a + b is still the exact same. Roc has to deal with static dispatch of Module.plus on some variable.
We of course can still make a numeric interface. Numeric(a) : a where a.plus, a.minus, .... we could even distinguish ints and fracs if we really wanted by making some sort of special method they implement opt in Frac(a) where Numeric(a), a.isFrac (a.isFrac is just a dummy method that returns nothing and is used to enable smarter interfaces and static dispatch, though may not be needed if Frac already contains unique functions).
This also means a user can implement a ComplexF32 and make it work in a function that takes types of the Frac(a) interface. So fundamentally makes out number types more flexible and friendly
The one issue I see with this is deciding on initialization of constant numbers likely will still need special handling still. At least if we want the same UX when writing x = 1.
hm, so what gets printed when I put 1 into roc repl?
1 : Numeric(a) I guess?
Yeah, I think we have a few options. i think the most seamless would be to make it pick the interface by default and then specialize from there
in that design, yeah Frac and Int would have to be interfaces too
yep
could work!
we could even use the current names
like instead of Numeric, call it Num
this makes a lot of sense to me!
and because we're already special-casing numbers in the type checker to have custom compact representations, perf impact should be minimal in practice
so then modules could be Int, Dec, and Float and that's it?
I'm not sure if we even need Frac :thinking:
other than behind the scenes maybe
in the compact representation
I guess like a Frac.pi could be useful? haha
one of the reasons I wanted everything in Num originally was so that (in the module-qualified calling world before static dispatch) you wouldn't have to be remembering which functions went in which modules
so maybe we should still have a Num module just for constants, and the Frac alias could go in there
like instead of
Numeric, call itNum
Yeah, exactly, just used a different name in the thread to make it easier to follow, but the names could all be the same.
yeah this seems very promising!
I really like that it would make custom numeric types Just Work with number literals
like I can have a function that accepts MyFunkyComplexNumber and I can just call it passing 4.2
I think that works as long as we have part of the Frac interface be conversion functions like from_f32 etc.
Oh wow, yeah. That would be really cool
I sketched out a pass at what the actual modules could look like in this world:
I made up some syntax that I found myself really wanting while doing this:
Num(num) : num where module(num).{
abs : num -> num,
abs_diff : num, num -> num,
# ...many, many more of these
}
basically a way to say "the module where this type lives contains these several things"
I also am pretty sure this would Just Work but it's the first time I think I've written it out:
Frac(frac) : Num(frac) where module(frac)...
basically the Num(frac) type alias would expand to adding all the Num constraints onto the type's where
one thing I noticed while doing this exercise is that I like reading the docs for Dec a lot more than I like reading the docs for Float.roc or Int.roc
because Dec is just a totally normal-looking module; you have stuff like:
Dec := I128
is_approx_eq : Dec, Dec -> Bool,
div : Dec, Dec -> Dec,
div_checked : Dec, Dec -> Result(Dec, [DivByZero]),
compare that to Int.roc:
Integer(size) := [] # builtin implementation isn't visible in the .roc file
I8 : Integer(Signed8)
U8 : Integer(Unsigned8)
I16 : Integer(Signed16)
U16 : Integer(Unsigned16)
I32 : Integer(Signed32)
U32 : Integer(Unsigned32)
I64 : Integer(Signed64)
U64 : Integer(Unsigned64)
I128 : Integer(Signed128)
U128 : Integer(Unsigned128)
# These just need to be nominal types so they don't unify with any other type
Signed8 := []
Unsigned8 := []
Signed16 := []
Unsigned16 := []
Signed32 := []
Unsigned32 := []
Signed64 := []
Unsigned64 := []
Signed128 := []
Unsigned128 := []
# Functions in Num.Int
div_ceil : Integer(size), Integer(size) -> Integer(size),
div_ceil_checked : Integer(size), Integer(size) -> Result(Integer(size), [DivByZero]),
all the types in the module have a different name from the module's name (it's Int.roc but they're Integer) and they have a type parameter (Integer(size), not Integer or Int) - whereas in Dec.roc all the functions take a Dec, just like normal
it makes me think that maybe in this world, it's better to have a separate module for each integer type
even though there would be a lot of them, reading the docs for each of them would be as nice as the docs for Dec because they'd each be totally normal modules:
I64 := []
div_ceil : I64, I64 -> I64
div_ceil_checked : I64, I64 -> Result(I64, [DivByZero])
this is what Rust does; e.g. here's the i64 module
even though it would make the sidebar in https://www.roc-lang.org/builtins much longer, I think I'd prefer this
Yeah that makes sense to me
we could put all the specific number types at the end of the docs
so it would be like
BoxNumDecF32F64U8I8on the one hand, I don't love having so many modules in the builtin docs, but on the other hand, I definitely like each of those modules being much nicer to read than today
it would get rid of a ton of type parameters in number function signatures
add : I32, I32 -> I32
bam
I agree that it is file duplication, but I think I is overall cleaner this way
yeah I think this is a good change
I think we'll want to special-case numbers even more, to avoid having to load 14 sizeable builtin modules instead of just Num.roc like today, but I think that would benefit overall perf anyway
doing things in this way will really make builtins a lot more useful for learning how to use the language well I think
today the way numbers work is almost like an exercise in what not to do, because the Num(FloatingPoint(Binary64)) technique is extremely niche and should not be used regularly :sweat_smile:
Richard Feldman said:
I think we'll want to special-case numbers even more, to avoid having to load 14 sizeable builtin modules instead of just Num.roc like today, but I think that would benefit overall perf anyway
To be fair, I think they would mostly be empty modules with just a bunch of prototypessl linked to zig, but I'm sure we can find ways to optimize them
hm, so what type would we print in roc repl if you just put 42 in there? :thinking:
today it would be Num(size) but in this design, I think it would need to be a type variable with an attached where clause
Yeah......would be weirder if we don't have inline interface specification
42 : a where Num(a)
right
but type of a is defined by its use because a implements Num implicitly. So in this case it can only be 42 : a.
Or with enlisted possible types 42 : U8 or U16 or U32 or ... which is honest but insane :smile:
Although every numeric type can be restricted to implement the interface explicitly:
# U8.roc
U8 : a where [ a : Num ]
It also gives guarantee that Num is fully implemented on this type
Hmm....42 : a is also not correct cause it loses info about the type
I think the fact it is a number might have to make it automatically implement the interface. Like consider it to have passed through some sort of constant initialization function
But yeah...it's odd
Like, init_num : num_literal -> num where [ num : Num ]. Not that odd if to consider this function as decode implemented for Num interface
This is kind of a revealation for me. Literals are macros. I never thought of them that way
On the other hand, that's not right as well. Custom types that implement Num won't be able to init from a num literal:
x : MyWeirdNumber
x = 42
So num literals can represent only a specific set of types
But it may look like that (let's pretend we have a comptime function)
U8.@from_literal : lit -> U8
...
Num.@from_literal : lit -> type
Num.@from_literal = | lit | {
module(type).@from_literal(lit)
}
So if type doesn't implement @from_literal - it's a type error. And only intrinsics can implement it
x : U8
x = 42
y : MyNum
y = 42 # type error: MyNum doesn't implement @from_literal
my plan there was just that if you're making a custom number type, you need to have something like
try_from_str : Str -> Result(MyType, InvalidNumStr)
and then we call that at compile time with a string that we've already verified is all digits (and we removed any _s) and the function returns Err then we give a compile-time error
but yeah I don't think a separate language feature is warranted here; the primitives we already have seem totally sufficient for that, and I'd definitely prefer not to introduce new primitives for this!
going back to the repl thing, one possible design is:
>> 42
42 : I128
>> 0.1 + 0.2
0.3 : Dec
arguably this is correct in that, although from a type-checking perspective these would still be constrained type variables, from an evaluation perspective these are the numeric types we would have resolved them to
so on the one hand, this repl design would answer a question people might currently have about like "ok but how big is my Num thingy?"
on the other hand, it might also make people confidently incorrect about how number literals work
because if you're a beginner, and your mental model is that 42 has the type I128, then you can't pass it to a function that needs a U8
that said, the incorrect mental model is incorrect in a way that might not have any consequences because what you find in practice is that number literals just work the way you want them to
one other argument for that design is that it optimizes for absolute beginners and absolute experts at the expense of intermediate Roc programmers
absolute beginners will see a less intimidating type, which could prevent them from going on a learning tangent that would be unhelpful for them to go on this early
for experts, knowing what concrete type the repl is actually using at runtime is helpful info
for intermediate programmers, trying to understand how number literals work, it might make that process take a bit longer - although it might be fine if we explicitly explain in the docs why you're seeing I128 and Dec
I think that would work
But yeah, num interface definitely more complex in some cases even if it is generally cleaner
Suppose you write on the repl:
>> x = 42
42 : I128
>> inc_u8 x
43 : U8
Calling a made-up defined inc_u8 : U8 -> U8 function defined earlier, would that work and reinterpret x as a U8?
Having those REPL results below each-other like that I can imagine being confusing, but maybe not confusing enough to warrant the more complex type.
Half-joking: maybe when you enter the second command, the REPL should go back and change I128 in the previous command to U8 :sweat_smile:. Might not even be half-bad from a learning perspective, you'd see the type system responding to new information
honestly, num is already special, so I wouldn't mind special casing it as Num(a) anyway....
I mean, x : num where [num : Num] is consistent and not magical. The only downside is that for a beginner there are too many words in the type definition. But all of them are "num" so what's the problem?:smile:
Yes, the concept is not trivial. But I think using I128 when the type is not fully inferred is much more confusing.
In rust, the fallback type of num literal is i32. Once I was fighting a subtle bug in a macro that relied on inference. If I'm not mistaken, it was bit shifting and eventual typecasting to higher range. Like, (a << b) as u32 when both a and b were expected to be u8, but they falled back to i32. I would save a couple of neurons if the type was required to be explicit.
another possible design is to not print a type for unbound numbers
we could do the same for strings, since "Hello, World!" : Str is not telling you anything you didn't already know
so then it would be:
>> 42
42
>> 0.1 + 0.2
0.3
I think this is my favorite so far
it's not confusing because it's not printing anything misleading
it's the most beginner-friendly imo because it lets you focus on values without being confronted with types yet
Omg, that looks really cool!
I actually always start out my repl introductions with "just ignore the : Str part for now" and it would be awesome to not have to do that
we only omit the type when it's unbound, so it's not even hiding information - once you know that rule, the repl is telling you (by omission) what the type is
(Assuming the beginner doesn't know about dependent types or knows that roc doesn't have them)
yeah I figure anyone with advanced type system knowledge will not need special help to understand Roc's type system :smile:
I'm focused on the beginners who aren't familiar with this stuff
Well. Getting back to my example from rust: what should be in repl?
(42).shift_left_by(3)
Actually, how does it work now?
right now if we're compiling a number and its type variable is still unbound, then it's treated as either an I128 or a Dec depending on whether the original literal had a decimal point
Richard Feldman said:
I actually always start out my repl introductions with "just ignore the
: Strpart for now" and it would be awesome to not have to do that
Maybe we could have a REPL config option command which can toggle whether types are shown. Something like :set types or anything else. If the default is off, beginners don't see it, but then you can toggle it if needed.
Something I don't like in haskell is how often I type the same expression twice in the REPL. Once with :t, once without. With the above option this is not needed anymore
:thinking: what would be the advantage of that compared to the purposed design of just omitting types for unbound number and string literals?
sounds more complicated but I don't see a benefit
Omitting the type in cases like this sounds good to me :)
If Roc offers a way for beginners/intermediate users to explore the type system such as :t, online repl or playground, then perhaps we want more info there.
When I was learning Haskell I was using the repl, ghcid and :t a lot and sometimes it didnt tell me enough information for me to understand it, and often I couldn’t google my way further because the output didn’t give me anything googleable.
Perhaps Roc could offer a few words to explain number types, a link to docs, or a “code” to look up (like an error code or PEP)? That would have saved me some frustration in Haskell. This could be only in certain tools or modes so that experienced users aren’t annoyed.
my thinking is just that we shouldn't have :t because we just always show type info (except in the case of string and number literals, at which point you can tell what the type is anyway just from the outputted literal)
in other words, adding :t wouldn't give you any new powers
also if you put in just an uppercase name, we could treat that as by default a request to look up a type name in scope
as opposed to just putting in a tag expression (since what would be the purpose of evaluating a single tag?)
Yeah, I don’t think Roc needs :t either. Perhaps I should have led with this, but I think the number discussion above and the outcome is great :)
I was just sharing a _minor_ issue that I’ve felt when learning languages and I’m told what a type is in an abstract way while I’m thinking more concretely because I wasn’t that deep into the language yet.
Last updated: Jun 16 2026 at 16:19 UTC