Stream: compiler development

Topic: number type inference


view this post on Zulip Richard Feldman (Nov 16 2025 at 20:04):

ok, while working on the epic https://github.com/roc-lang/roc/pull/8368 I have learned some things - I want to do a quick note of them here:

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:08):

number primitive types

we have the num_compact, num_unbound etc. behind-the-scenes types, which were a nice performance optimization for the old Num system (while also being a shortcut to getting something working for number literals and basic arithmetic ops), but which are now mostly in the way now that we have actual nominal types and no longer want to represent numbers as opaque Num types with nested phantom types.

I think we can do something similar in the static dispatch world, but trying to transition it at the same time as transitioning number literals over to the new design has made it extremely difficult. I think based on my experience so far, it will be better to just take out the number primitves from the type system, use only the actual nominal types we now have in Builtin.roc, and then revisit the primitives (strictly as a performance optimization) in the future once we have all of this working.

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:12):

methods for converting literals

the way to make numbers be ordinary opaque types is to have the type of number literals be polymorphic:

my_integer : num
    where from_int_digits : List(U8) -> Try(num, [OutOfRange])
my_integer = 5

my_fraction : num
    where from_dec_digits : (List(U8), List(U8)) -> Try(num, [OutOfRange])
my_fraction = 4.2

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:14):

so basically, if you make a custom type which defines from_int_digits then you can define it using integer literals like 5, and if you make a custom type which defines from_dec_digits then you can define it using decimal literals like 4.2

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:14):

this creates a slightly weird situation where you can define a custom number type that defines from_dec_digits but not from_int_digits, and then you can create one using 3.0 but not 3.

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:15):

I'd prefer if the API were such that you essentially had to specify which you wanted

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:15):

instead of being able to forget to handle one case

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:16):

there's another problem, which is that in the future I'd like to do the same thing with string literals, which will mean that we can still have stuff like File.read! : Path => ... and yet you can give it a string literal like File.read!("foo.txt") because Path has a from_str_literal method (or similar) that does the same thing as from_int_digits etc.

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:18):

the reason this is a problem is that if these are all just polymorphic method constraints, then this code...

x = 4.7 / "cheese"

...will type-check, and in fact if x is a custom type which (for whatever reason) defines all of from_dec_digits and from_str_literal and div then it will do...something :sweat_smile:

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:25):

you could look at it as a feature that this can work, but there's a nonobvious downside: if 4.7 / "cheese" happily type-checks, then the same is true if you are giving them names in a quick script where you're not annotating types because it's a quick script:

x = 4.7
y = "cheese"

# ...somewhere later in the code

if x / y > 0 { ... }

this will give a compile-time error, but I don't think it'll be a nice one - because according to these rules, everything in that code type-checks!

view this post on Zulip Jared Ramirez (Nov 16 2025 at 20:25):

i really like removing the number types from the type system. it made sense for the polymorphic types, but agree they felt error proned with a less clear perf gain in the static dispatch world

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:28):

the type of x / y > 0 here is totally valid and internally consistent; it's something like:

view this post on Zulip Jared Ramirez (Nov 16 2025 at 20:31):

yeah that is throrny, on the one hand that flexibility is really cool. on the other, it hurts the beginner experience and error message quality

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:31):

potentially, the latest point at which you'd actually get a compiler error would be that we make it to compile-time evaluation of constants, resolve x = 4.7 to be an I128 by default (because it didn't get a more specific number type by the end of type-checking), and similarly y becomes a concrete Str) - so then at that point when we go to evaluate x / ywe get an error saying that x defaulted to I128 and y defaulted to Str and I128.div only accepts another I128, not a Str

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:32):

an idea for how to catch this earlier during type-checking: change all of them to have a from_literal method, which takes a different nominal type for its first argument so that the incompatibility comes up right away

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:32):

so we'd have:

I128.from_literal : Num.Literal -> Try(I128, [Unsupported])
Str.from_literal : Str.Literal -> Try(Str, [Unsupported])

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:33):

then:

x : num where [num.from_literal : Num.Literal -> Try(num, [Unsupported])
x = 4.7

y : str where [str.from_literal : Str.Literal -> Try(str, [Unsupported])
y = "cheese"

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:34):

so now it's impossible to have a custom type that can be represented as both a string literal and as a number literal

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:35):

because you have to pick one to use with your from_literal method; Num.Literal and Str.Literal would be different nominal types

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:38):

then Num.Literal would have both integer and decimal digits, and for integer types (like I128) you'd just return Err if given a nonzero decimal - basically the same as if you give an U8 something over 255

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:39):

all that said, I don't have much intuition for how much this will come up in practice. It might turn out that it's fine, and maybe even nice, to support that level of flexibility

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:40):

one concrete example I just thought of is UI elements

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:41):

e.g. in Elm, when you look at the classic UI example of a counter that increases or decreases a number example, part of it is:

div [] [ text (String.fromInt model) ]

so you need to take a number and convert it to a string and then pass that string to a text to display it

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:41):

but with the string literal design mentioned above, you could give your UI element type a from_str_literal method, allowing you to just go [button1, button2, "some text!"] without having to call text on it to convert it to a UI element type

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:42):

and then if we supported converting from numbers too, then you could also display numbers in the same way without having to convert them to strings first, e.g. [button1, button2, current_count] could Just Work too

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:42):

but that would require UI elements being able to define both from_str_literal and from_num_literal at the same time

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:43):

so it's probably worth doing the experiment and seeing what actual concrete error messages turn out to be annoying in the wild (or not - maybe they're all fine in practice!)

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:43):

rather than ruling that out before we've even tried it

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:44):

so my conclusion here is mostly that I want to note all of this, as something I've learned while working on that PR, in case we encounter a problem in practice and need to revisit potential solutions later :smile:

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:45):

evaluating default numbers

1 + 2 desugars into 1.plus(2), which currently has this type:

ret where [
    a.plus : a, b -> ret,
    a.from_int_digits : List(U8) -> Try(a, [OutOfBounds]),
    b.from_int_digits : List(U8) -> Try(a, [OutOfBounds]),
]

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:46):

we have the rule that integers default to I128 if not otherwise specified, so that for example beginners can put 1 + 1 into the repl and get an answer instead of an error saying in this language you need to be more specific about what type of number you want :stuck_out_tongue:

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:46):

(and similar for decimal literals and Dec)

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:47):

now, the trouble is - both the interpreter and the future optimizing compiler need to know at compile time the size in bytes of return types before they call a function

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:47):

this is necessary so they can know how much space to allocate in the caller for the return type

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:48):

(note that even if we somehow changed the interpreter to not need this, which I don't think would be the right choice for reasons that are out of scope here, the optimizing compiler would still need it - so we should assume that we need to preserve this property)

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:48):

the problem here is that the inferred type (ret where ...) doesn't actually capture that this is "a number"

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:50):

so even if we know that "numbers default to I128", when we try to evaluate the expression 1 + 2, we don't know after type checking that the return type is a number - and therefore we don't know how much space to allocate

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:51):

now one thing we could theoretically do here is to try to unify I128 with ret where [...] and see that it succeeds, and therefore conclude that this is compatible with I128, and so I128 is a reasonable default, and therefore we should use it

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:51):

however, even if that did seem like a good approach for this scenario, it falls apart in this example:

(|a, b| a + b)(1, 2)

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:52):

this should, of course, do the same thing as just running 1 + 2

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:52):

it would be very strange if 1 + 2 worked but (|a, b| a + b)(1, 2) didn't, or if they got different answers!

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:53):

the problem is that the type of 1 + 2 is:

ret where [
    a.plus : a, b -> ret,
    a.from_int_digits : List(U8) -> Try(a, [OutOfBounds]),
    b.from_int_digits : List(U8) -> Try(a, [OutOfBounds]),
]

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:54):

...and the type of (|a, b| a + b) is the same, but instead of ret at the start it's a, b -> ret

a, b -> ret where [
    a.plus : a, b -> ret,
    a.from_int_digits : List(U8) -> Try(a, [OutOfBounds]),
    b.from_int_digits : List(U8) -> Try(a, [OutOfBounds]),
]

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:58):

so if we try to call this with 1 - which has the type a where a.from_int_digits : List(U8) -> Try(a, [OutOfBounds]) - then what we'd like to have happen is that the literal 1 would pick up the plus constraint too, and now be required to have both from_int_digits (which it has by default because it's a number literal) as well as plus (because it unified with a in a, b -> ret and a is required to have plus)

view this post on Zulip Richard Feldman (Nov 16 2025 at 20:58):

I'm pretty sure this is what should already happen, although I haven't tried it

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:00):

anyway, despite being more convoluted than the non-lambda version, I think this example should work out just like 1 + 2 in that if we were to try unifying I128 with the unbound value, it should work

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:03):

but there's something that does feel concerning about all of those examples, namely that it basically means anytime we try to eval an unresolved polymorphic type, or a function that's returning one, we have to basically assume it's a number (and try to unify it accordingly) even though we don't really have a reason to assume that :sweat_smile:

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:04):

and in the world where we're doing it with strings too, we'd probably have to just try both (perhaps after looking for the obvious form_num_literal/from_str_literal in the constraints as a heuristic for which to try first) - probably not a big deal in practice, given that these defaults basically only ever come up in practice in the repl, or maybe in very quick scripts

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:06):

anyway, my conclusion from all of this is that:

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:06):

one default number type

based on all of the above, I think we actually want to just have one default number type: Dec

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:06):

like, no more "integers default to I128 and non-integers default to Dec"

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:07):

it's just "if you have a number literal, regardless of whether there's a decimal point in it, if it doesn't resolve to any other type, we just resolve it to Dec"

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:08):

this obviously works fine from a runtime perspective, since Dec (unlike floats) is perfectly capable of performing both precise base-10 integer arithmetic as well as precise base-10 decimal arithmetic

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:09):

but it means that we can have just one from_num_literal method which uses the returned value being either Ok or Err (when being evaluated at compile time) to handle both the error case of the literal being out of bounds (e.g. negative number literals being used with unsigned integers, or the number literal being too big to fit in the number - and now additionally, integers being given decimals would be reported the same way)

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:10):

and then at that point, when trying to figure out the runtime type of an unbound number literal, we just unify it with Dec and we're all set

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:17):

summary

I think based on all these learnings, I want to:

view this post on Zulip Richard Feldman (Nov 16 2025 at 21:27):

any thoughts on all of that welcome!

view this post on Zulip Luke Boswell (Nov 16 2025 at 21:54):

This sounds like great progress. I like the simplification to just use Dec as default, and it sounds like the type system implementation will be simpler and easier to get right.

view this post on Zulip Richard Feldman (Nov 16 2025 at 22:07):

yeah, these are all things I wish I'd known earlier, but as usual, actually implementing things is often the fastest way to learn them :joy:

view this post on Zulip Jared Ramirez (Nov 16 2025 at 22:14):

all sounds good to me! the simpler type system, the easier to ensure correctness — so big fan of removing hardcoded variants!

view this post on Zulip Richard Feldman (Nov 16 2025 at 22:29):

ok cool! Unfortunately this means we can't do more number builtin stuff until all of this lands :sweat_smile:

view this post on Zulip Mike (Nov 17 2025 at 16:13):

Richard Feldman said:

so now it's impossible to have a custom type that can be represented as both a string literal and as a number literal

Not sure if completely related but this reminded me of your convo with Chris Lattner re: swift's type checker and how a lot of complexity/combinatorial explosions came from all the ExpressibleBy...Literal protocols (traits)

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

I don't think this is related

view this post on Zulip Richard Feldman (Nov 17 2025 at 17:15):

we don't try multiple options the way Swift does

view this post on Zulip Richard Feldman (Nov 17 2025 at 17:15):

merging method constraints is basically the same as merging record field constraints


Last updated: Nov 28 2025 at 12:16 UTC