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:
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.
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
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
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.
I'd prefer if the API were such that you essentially had to specify which you wanted
instead of being able to forget to handle one case
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.
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:
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!
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
the type of x / y > 0 here is totally valid and internally consistent; it's something like:
y has from_str_literalx has from_dec_digits and divx's div takes something compatible with y and returns something with is_gtis_gt takes something that has from_int_digits (from the 0) and returns Bool (because if requires Bool)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
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
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
so we'd have:
I128.from_literal : Num.Literal -> Try(I128, [Unsupported])
Str.from_literal : Str.Literal -> Try(Str, [Unsupported])
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"
so now it's impossible to have a custom type that can be represented as both a string literal and as a number literal
because you have to pick one to use with your from_literal method; Num.Literal and Str.Literal would be different nominal types
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
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
one concrete example I just thought of is UI elements
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
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
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
but that would require UI elements being able to define both from_str_literal and from_num_literal at the same time
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!)
rather than ruling that out before we've even tried it
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:
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]),
]
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:
(and similar for decimal literals and Dec)
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
this is necessary so they can know how much space to allocate in the caller for the return type
(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)
the problem here is that the inferred type (ret where ...) doesn't actually capture that this is "a number"
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
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
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)
this should, of course, do the same thing as just running 1 + 2
it would be very strange if 1 + 2 worked but (|a, b| a + b)(1, 2) didn't, or if they got different answers!
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]),
]
...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]),
]
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)
I'm pretty sure this is what should already happen, although I haven't tried it
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
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:
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
anyway, my conclusion from all of this is that:
from_whichever_literal as a heuristic to only bother unifying with the one that's more likely to succeedbased on all of the above, I think we actually want to just have one default number type: Dec
like, no more "integers default to I128 and non-integers default to Dec"
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"
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
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)
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
I think based on all these learnings, I want to:
num_compact, num_unbound, num_poly etc. system and revisit it after we get all the new stuff workingfrom_num_literal : NumLiteral -> Try(MyNumberType, [Unsupported(Str)])Num.NumLiteral to have methods like is_negative, digits_before_decimal, digits_after_decimal, etc.from_str_literal in the future for stringsDec, never I128Dec (and if that unification fails, then evaluate it to a crash)any thoughts on all of that welcome!
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.
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:
all sounds good to me! the simpler type system, the easier to ensure correctness — so big fan of removing hardcoded variants!
ok cool! Unfortunately this means we can't do more number builtin stuff until all of this lands :sweat_smile:
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)
I don't think this is related
we don't try multiple options the way Swift does
merging method constraints is basically the same as merging record field constraints
Last updated: Nov 28 2025 at 12:16 UTC