Stream: ideas

Topic: Do we need Num anymore?


view this post on Zulip Brendan Hansknecht (Jul 04 2025 at 14:25):

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

view this post on Zulip Brendan Hansknecht (Jul 04 2025 at 14:26):

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

view this post on Zulip Brendan Hansknecht (Jul 04 2025 at 14:32):

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.

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:31):

hm, so what gets printed when I put 1 into roc repl?

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:31):

1 : Numeric(a) I guess?

view this post on Zulip Brendan Hansknecht (Jul 04 2025 at 16:32):

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

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:33):

in that design, yeah Frac and Int would have to be interfaces too

view this post on Zulip Brendan Hansknecht (Jul 04 2025 at 16:33):

yep

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:33):

could work!

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:33):

we could even use the current names

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:34):

like instead of Numeric, call it Num

view this post on Zulip Anthony Bullard (Jul 04 2025 at 16:35):

this makes a lot of sense to me!

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:37):

and because we're already special-casing numbers in the type checker to have custom compact representations, perf impact should be minimal in practice

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:39):

so then modules could be Int, Dec, and Float and that's it?

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:39):

I'm not sure if we even need Frac :thinking:

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:39):

other than behind the scenes maybe

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:39):

in the compact representation

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:40):

I guess like a Frac.pi could be useful? haha

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:43):

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

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:44):

so maybe we should still have a Num module just for constants, and the Frac alias could go in there

view this post on Zulip Brendan Hansknecht (Jul 04 2025 at 16:49):

like instead of Numeric, call it Num

Yeah, exactly, just used a different name in the thread to make it easier to follow, but the names could all be the same.

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:53):

yeah this seems very promising!

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:54):

I really like that it would make custom numeric types Just Work with number literals

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:55):

like I can have a function that accepts MyFunkyComplexNumber and I can just call it passing 4.2

view this post on Zulip Richard Feldman (Jul 04 2025 at 16:56):

I think that works as long as we have part of the Frac interface be conversion functions like from_f32 etc.

view this post on Zulip Brendan Hansknecht (Jul 04 2025 at 17:21):

Oh wow, yeah. That would be really cool

view this post on Zulip Richard Feldman (Jul 05 2025 at 00:58):

I sketched out a pass at what the actual modules could look like in this world:

view this post on Zulip Richard Feldman (Jul 05 2025 at 00:59):

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
}

view this post on Zulip Richard Feldman (Jul 05 2025 at 00:59):

basically a way to say "the module where this type lives contains these several things"

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:00):

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

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:00):

basically the Num(frac) type alias would expand to adding all the Num constraints onto the type's where

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:01):

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

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:02):

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]),

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:03):

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

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:03):

it makes me think that maybe in this world, it's better to have a separate module for each integer type

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:04):

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

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:04):

this is what Rust does; e.g. here's the i64 module

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:05):

even though it would make the sidebar in https://www.roc-lang.org/builtins much longer, I think I'd prefer this

view this post on Zulip Brendan Hansknecht (Jul 05 2025 at 01:05):

Yeah that makes sense to me

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:06):

we could put all the specific number types at the end of the docs

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:07):

so it would be like

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:12):

on 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

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:12):

it would get rid of a ton of type parameters in number function signatures

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:12):

add : I32, I32 -> I32

view this post on Zulip Richard Feldman (Jul 05 2025 at 01:12):

bam

view this post on Zulip Brendan Hansknecht (Jul 05 2025 at 01:38):

I agree that it is file duplication, but I think I is overall cleaner this way

view this post on Zulip Richard Feldman (Jul 05 2025 at 12:57):

yeah I think this is a good change

view this post on Zulip Richard Feldman (Jul 05 2025 at 13:00):

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

view this post on Zulip Richard Feldman (Jul 05 2025 at 13:01):

doing things in this way will really make builtins a lot more useful for learning how to use the language well I think

view this post on Zulip Richard Feldman (Jul 05 2025 at 13:02):

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:

view this post on Zulip Brendan Hansknecht (Jul 05 2025 at 16:21):

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

view this post on Zulip Richard Feldman (Jul 13 2025 at 03:23):

hm, so what type would we print in roc repl if you just put 42 in there? :thinking:

view this post on Zulip Richard Feldman (Jul 13 2025 at 03:24):

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

view this post on Zulip Brendan Hansknecht (Jul 13 2025 at 03:26):

Yeah......would be weirder if we don't have inline interface specification

42 : a where Num(a)

view this post on Zulip Richard Feldman (Jul 13 2025 at 03:31):

right

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 05:17):

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:

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 05:24):

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

view this post on Zulip Brendan Hansknecht (Jul 13 2025 at 05:43):

Hmm....42 : a is also not correct cause it loses info about the type

view this post on Zulip Brendan Hansknecht (Jul 13 2025 at 05:43):

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

view this post on Zulip Brendan Hansknecht (Jul 13 2025 at 05:43):

But yeah...it's odd

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 06:23):

Like, init_num : num_literal -> num where [ num : Num ]. Not that odd if to consider this function as decode implemented for Num interface

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 06:36):

This is kind of a revealation for me. Literals are macros. I never thought of them that way

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 09:38):

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

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 09:45):

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

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:29):

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)

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:30):

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

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:31):

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!

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:34):

going back to the repl thing, one possible design is:

>> 42
42 : I128
>> 0.1 + 0.2
0.3 : Dec

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:35):

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

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:36):

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?"

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:37):

on the other hand, it might also make people confidently incorrect about how number literals work

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:38):

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

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:39):

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

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:40):

one other argument for that design is that it optimizes for absolute beginners and absolute experts at the expense of intermediate Roc programmers

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:41):

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

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:41):

for experts, knowing what concrete type the repl is actually using at runtime is helpful info

view this post on Zulip Richard Feldman (Jul 13 2025 at 11:52):

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

view this post on Zulip Brendan Hansknecht (Jul 13 2025 at 12:42):

I think that would work

view this post on Zulip Brendan Hansknecht (Jul 13 2025 at 12:42):

But yeah, num interface definitely more complex in some cases even if it is generally cleaner

view this post on Zulip Jasper Woudenberg (Jul 13 2025 at 18:08):

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

view this post on Zulip Brendan Hansknecht (Jul 13 2025 at 18:09):

honestly, num is already special, so I wouldn't mind special casing it as Num(a) anyway....

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 18:43):

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.

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:02):

another possible design is to not print a type for unbound numbers

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:03):

we could do the same for strings, since "Hello, World!" : Str is not telling you anything you didn't already know

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:04):

so then it would be:

>> 42
42
>> 0.1 + 0.2
0.3

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:04):

I think this is my favorite so far

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:05):

it's not confusing because it's not printing anything misleading

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:05):

it's the most beginner-friendly imo because it lets you focus on values without being confronted with types yet

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 19:06):

Omg, that looks really cool!

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:06):

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

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:07):

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

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 19:08):

(Assuming the beginner doesn't know about dependent types or knows that roc doesn't have them)

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:10):

yeah I figure anyone with advanced type system knowledge will not need special help to understand Roc's type system :smile:

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:10):

I'm focused on the beginners who aren't familiar with this stuff

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 19:24):

Well. Getting back to my example from rust: what should be in repl?

(42).shift_left_by(3)

Actually, how does it work now?

view this post on Zulip Richard Feldman (Jul 13 2025 at 19:41):

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

view this post on Zulip Kilian Vounckx (Jul 13 2025 at 20:58):

Richard Feldman said:

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

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

view this post on Zulip Richard Feldman (Jul 13 2025 at 21:03):

:thinking: what would be the advantage of that compared to the purposed design of just omitting types for unbound number and string literals?

view this post on Zulip Richard Feldman (Jul 13 2025 at 21:03):

sounds more complicated but I don't see a benefit

view this post on Zulip Anton (Jul 14 2025 at 06:13):

Omitting the type in cases like this sounds good to me :)

view this post on Zulip Niclas Ahden (Jul 14 2025 at 17:13):

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.

view this post on Zulip Richard Feldman (Jul 14 2025 at 17:25):

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)

view this post on Zulip Richard Feldman (Jul 14 2025 at 17:25):

in other words, adding :t wouldn't give you any new powers

view this post on Zulip Richard Feldman (Jul 14 2025 at 17:26):

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

view this post on Zulip Richard Feldman (Jul 14 2025 at 17:26):

as opposed to just putting in a tag expression (since what would be the purpose of evaluating a single tag?)

view this post on Zulip Niclas Ahden (Jul 14 2025 at 18:56):

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