FYI, I discovered that we need to make binops have a type constraint where both sides are equal after all (I previously thought we could get away without doing this, but turns out not!)
example situation where the unconstrained design (where e.g. a * b desugars to a.times(b) and that's it, a and b are permitted to have totally different types) breaks down:
answer : Answer
answer = {
my_custom_number : MyVector
my_custom_number = 42
5.times(my_custom_number)
}
(that's after desugaring 5 * my_custom_number to 5.times(my_custom_number))
the type of that 5 literal is:
a where [
a.from_numeral : a -> Try(a, InvalidNumeral(Str)),
a.times : a, MyVector -> Answer,
]
since nothing is forcing this to be concrete, this ends up "defaulting to Dec"
however, the way that works is that we unify Dec with this type - which fails, because Dec.times : Dec, Dec -> Dec fails to unify with a.times : a, MyVector -> Answer even if Answer turns out to be a type alias for Dec, because Dec will fail to unify with MyVector
so we know it can't be Dec, but we can't leave it as unbound, so...what do we do?
one design is that we give an error, which means Roc is a language where you can't write 2 * x or 1 + x etc. - which would be absurd; obviously we're not going with that design
another design is where we do our best to guess what number type this should be, since it can't be Dec but we also can't leave it as an unbound variable because we have no how to do operations on that
so how would we guess? the only clue we have to go on in that entire type is MyVector, so even if we hand-wave away what the exact heuristic is, it would have to conclude that MyVector is the type to go with
at which point there's only one thing for it to do: unify MyVector with this type, which in turn means it has to unify MyVector.times : MyVector, _ -> _ with a.times : a, MyVector -> Answer - a type where the only way it can _possibly_ successfully unify is if MyVector.times has both arguments being the same type
at which point the final design option, which I conclude is the correct one by process of elimination, is just a strictly clearer and more performant (in terms of compiler performance, due to not needing to have a heuristic for howto guess this) design compared to the second option, is:
we require that both sides of the binop have the same type
so it's not _just_ a * b is sugar for a.times(b), it's that it's a * b is sugar for "a.times(b) where a and b have the same type"
at that point this is all trivial because they just unify
so none of these problems exist
because 5.times(blah) just unifies to both arguments of times being the same, which means we just end up dispatching on whatever the type of blah is and we're all set
I think one of a common patterns in scientific computing which as far as I know is a motivation to have "operator overloading" is to enable stuff like "5 * x" where x is not necessarily another number, but can be a vector or a matrix or a tensor.
Will this design limit those use cases?
I think you could implement a from_numeralto turn the number into the same type as the matrix. Might be a bit of effort so that both 5 + x and 5 * x all do the right thing, but I think it should be possible?
Last updated: Nov 28 2025 at 12:16 UTC