Stream: announcements

Topic: Breaking change: range syntax now returns a `Num.Range`


view this post on Zulip Richard Feldman (Aug 14 2026 at 03:46):

This shouldn't affect any existing uses of ranges in for loops, but: previously, range syntax (e.g. 1..=5) returned an Iter, and now (as of this PR) it returns a Range, which is a new builtin type with .iter(), .iter_rev(), and step_by().

It also automatically provides a known length to iteration when possible (sometimes it's too big, e.g. the total length if you actually iterated through it all would exceed U64.highest, but that should be rare in practice).

view this post on Zulip Aurélien Geron (Aug 14 2026 at 11:23):

The most common broken code samples in exercism/roc look like:

I've temporarily changed these to the following, but it would probably be better to add a few functions to Num.Range:

view this post on Zulip Jasper Woudenberg (Aug 14 2026 at 11:32):

I've taken a stab at integrating the new range API for my date/time library gregorian, and I've noticed there's quite a few additional methods required on the type to support the full API. Some like range_iter are needed for the internal workings of Range, but must be added to the public API of the type.

There's a difference between what I'd like documentation to communicate ("you can use ..= and ..< with this type") and what documentation is actually communicating (a dozen or so methods with non-intuitive types). To a smaller difference is also there (but smaller) for methods like .plus(). Maybe these kinds of API-contract-fulfilling functions could be collapsed in documentation?

For ranges in particular, what do you think of swapping out the concrete Range type with a where-alias, and let each type implementing range-functionality to bring its on implementation? The current generic Range implementation necessarily needs to delegate a lot of work to the underlying type it's a range of, creating all these extra methods in the public API. A concrete Range type for a specific kind of range could contain the full implementation of range-functionality, removing the need for a big contract between Range(a) and a.

view this post on Zulip Richard Feldman (Aug 14 2026 at 11:42):

Aurélien Geron said:

it would probably be better to add a few functions to Num.Range:

those all sound fine to me except for fold_map - I don't really think that one makes sense for ranges. :smile:

view this post on Zulip Richard Feldman (Aug 14 2026 at 11:43):

@Jasper Woudenberg can you show me some code examples? Are you talking about things like date1..=date2?

view this post on Zulip Jasper Woudenberg (Aug 14 2026 at 14:42):

Yes! So this is the old code I have for that:
https://git.sr.ht/~jwoudenberg/roc/tree/main/item/gregorian/Date.roc#L152-177

So it implements range_inclusive and range_exclusive, both functions aliases for a particular operator. The code I linked is for the Weekday type, in the same file I have the same for Month and Day.

If I understand the new API correctly I would need to implement for each of these types the following (public) methods:

view this post on Zulip Richard Feldman (Aug 14 2026 at 19:24):

gotcha - what did you have in mind as an alternative? I'm not totally sure how the "where instead of concrete Range" would work specifically :sweat_smile:

view this post on Zulip Jasper Woudenberg (Aug 14 2026 at 20:14):

I was thinking the following interface for types implementing ranges (taking Date as an example)

range_inclusive : Date, Date -> range where [range.Iteratable(Date)] # ..=
range_exclusive : Date, Date -> range where [range.Iteratable(Date)] # ..<

Iteratable(a) : range where [
  # Same type signatures these functions currently have.
  range.range_inclusive_to : ...,
  range.range_inclusive_from : ...,
  range.range_exclusive_to : ...,
  range.range_exclusive_from : ...,
  range.range_len_if_known : ...,
  range: range_iter : ...,
]

view this post on Zulip Jasper Woudenberg (Aug 14 2026 at 20:24):

Or, I guess simpler alternative: if the range syntax could be made to support descending ranges we might not need the Range type, because iter_rev can be expressed more directly. I think that would be nice, avoids the need for another type with a lot of API overlap to List and Iter.

To allow types to not support descending iteration we could have:

range_inclusive : Date, Date -> Try(Iter(Date), [DescendingNotSupported]) # ..=
range_exclusive : Date, Date -> Try(Iter(Date), [DescendingNotSupported]) # ..<

(using the fact both would be called at compile time)

The only functionality that Range offers not supported by the API then is efficient skipping-iterators, it could be left to the type to define custom methods for those where it makes sense.

view this post on Zulip Jasper Woudenberg (Aug 14 2026 at 20:39):

Writing that gave me an insight: the Range type felt funny to me and I think I understand why now: Range is not useful by itself, the only reason it exists is as a way-station between range-syntax like 0..<4 and an iterator that you actually use. It seems to me that the need for such an in-between is proof that the range syntax maybe isn't nice enough, or it would have sufficed by itself.

I think extending the range syntax so it can stand by itself, _or_giving Range an API that allows it to replace range operators fully, both seem nicer to me than the current design. Right now you have these two half-API's, (to be) documented in two different places (language spec and std docs respectively), that need to be glued together to do anything.

view this post on Zulip Richard Feldman (Aug 15 2026 at 13:09):

Jasper Woudenberg said:

I think extending the range syntax so it can stand by itself, _or_giving Range an API that allows it to replace range operators fully, both seem nicer to me than the current design.

hm, I'm not sure what either of these options would look like in practice :thinking:

do you have any specific ideas?

view this post on Zulip Jasper Woudenberg (Aug 15 2026 at 19:30):

Sure!

(A) simplest option:

Support descending ranges using existing operators, i.e. 5..=2 or 5..<2. The second exclusive operator is maybe a bit weird in a descending case, but only if you think about it for longer and even then I think the meaning is unambiguous, so I wouldn't change it. Because ranges can now be defined in either order, there's no need for iter_rev anymore, so range syntax can produce Iter directly and the Range type is no longer needed, as it was before the change announced in this thread.

If we want to allow types to forbid descending ranges for whatever reason, then we change the types of the functions for the operators like so:

range_inclusive : Date, Date -> Try(Iter(Date), [DescendingNotSupported]) # ..=
range_exclusive : Date, Date -> Try(Iter(Date), [DescendingNotSupported]) # ..<

Lost in this scenario: the option to specify a step-size with the range. This can be mediated in at least two ways:

Personally this option appeals to me. It seems simple and the step-size functionality built into Rangealone I don't think weigh up against the extra complexity. I don't know other languages that integrate step size into their concept of a range, nor use cases that become a lot nicer thanks to this change. Could be I missed some leading up to this decision though.

view this post on Zulip Jasper Woudenberg (Aug 15 2026 at 19:32):


(B) The option of dump range syntax in favor of going all in on creating a builder syntax for range:

Range.from(4).to_inclusive(5).step(3).iter()
Range.from(4).to_exclusive(5).step(3).iter()
Range.from(4).to_exclusive(1).step(3).iter()

Very flexible and could be made to support many more range-builder options if we come up with them. For instance, open-ended ranges could be supported.

This doesn't look as nice as the range operators, but has the advantage the Range module and docs contain all you need for building ranges. Also, the point where .iter() is called could call a single method on the underlying type passing all the options collected by the Range API.

Alternative API design using records and optional fields:

Range.iter({ start: Inclusive(4), end: Exclusive(5), step: 2 })

view this post on Zulip Jasper Woudenberg (Aug 15 2026 at 20:05):


(C) combining step sizes into range syntax. This is an extension of option (A) in case we don't want to compromise on step sizes.

One approach would be to do as Excel: let the user specify the first two values in a series and let Roc infer the step size from that:

(1, 3)..=9 # 1,3,5,7,9
(1, 3)..<9 # 1,3,5,7

This would require implementing range_inclusive and range_exclusive on the 2-tuple type, with the 2-tuple passing the values through through to two new methods on the type for which the range is constructed:

Date :: U32.{
    range_inclusive_step : Date, Date, Date -> Iter(Date)
    range_inclusive_step = |first, second, end| ...

    range_exclusive_step : Date, Date, Date -> Iter(Date)
    range_exclusive_step = |first, second, end| ...
}

Like in option (A) the extra methods for stepped iterations above could be made to return a Try(Iter(Date), [DescendingNotSupported]) if types need to be able to disallow descending ranges.

view this post on Zulip Aurélien Geron (Aug 15 2026 at 20:29):

Here's how other languages handle step size:

view this post on Zulip Jasper Woudenberg (Aug 15 2026 at 21:00):

Oh wow, I had no idea it was this common. I've worked with quite a few of these languages never realizing this syntax existed.

It looks like a fair amount of languages contain the step directly in their range syntax: Julia, F#, Elixir, Scala, Bash, Haskell

These languages appear to use regular functions for constructing ranges: Python, Swift, R

In the list above the languages that construct a range first, then let you call a function on that passing a step size: Kotlin, Rust, Ruby
Though for each of these Range is itself a type of iterable that can be passed to for loops directly.

view this post on Zulip Kasper Møller Andersen (Aug 16 2026 at 07:07):

Aurélien Geron sagde:

Here's how other languages handle step size:

It's worth noting that Scala does not have special range syntax, it's just using infix notation for two regular functions named to and by in this example. I don't know if something similar applies to any of the other examples.

And honestly, I would be happy to question the range syntax in general. It makes good sense to have a Range construct of some kind, but in my 10+ years of development, I feel I've only seen extremely trivial uses of the syntax. A few specific functions for building certain ranges seem like they would cover the use case, and they have the benefit of being named and documented, whereas looking up syntax is notably harder.

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:15):

Jasper Woudenberg said:

(A) simplest option:

Support descending ranges using existing operators, i.e. 5..=2 or 5..<2. The second exclusive operator is maybe a bit weird in a descending case, but only if you think about it for longer and even then I think the meaning is unambiguous, so I wouldn't change it. Because ranges can now be defined in either order, there's no need for iter_rev anymore, so range syntax can produce Iter directly and the Range type is no longer needed, as it was before the change announced in this thread.

doesn't this already work? :thinking:

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:16):

what I mean is that the range syntax just desguars into method calls, and the method can just look at its two arguments and decide what to do based on whatever it got

view this post on Zulip Jasper Woudenberg (Aug 16 2026 at 13:25):

True, I guess it's more of a matter of the standard library currently choosing to return an empty iteration instead of a descending iteration:

» List.from_iter((5..=2).iter())
[]

For consistency's sake I did the same with the iterators in gregorian, but technically I could have done something else.

I guess what option (A) comes down to then is to revert the introduction of the Range type and instead update range_inclusive and range_exclusive implementations to return a descending iterator when the destination number lies below the start number.

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:31):

ok so https://roc.zulipchat.com/#narrow/channel/397893-announcements/topic/Breaking.20change.3A.20removing.20Iter.2Erev.20and.20Iter.2Etake_last/near/616216896 was the original motivation for this, and stdlib allowing descending ranges would address that too I believe - right @Aurélien Geron?

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:33):

also a trick we can do to minimize runtime cost of that branch is to do it outside the iterator so it gets run at compile time in the common case where you're doing it with literals

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:34):

in other words, if ...check which arg is bigger... { return forward iterator } else { return reversed iterator }

view this post on Zulip Aurélien Geron (Aug 16 2026 at 19:57):

Yes, I'd be totally happy with descending ranges. Just one more thing: (0..<1_000_000_000).step_by(1_000_000) should be efficient (it shouldn't do a 1-by-1 iterator and then skip 99.9%).

view this post on Zulip Richard Feldman (Aug 16 2026 at 20:22):

yeah that last one I think we can do if we make "step by" be a thing you specify when creating the iterator

view this post on Zulip Richard Feldman (Aug 16 2026 at 20:23):

as in, we add Iter.step_by and let you customize how it works

view this post on Zulip Aurélien Geron (Aug 16 2026 at 20:44):

This sounds perfect to me. If I can write for i in 10..=1 and for i in (1..=1_000_000_000).step_by(1_000_000), and (1..=10).map(...) and (1..=10).collect() and (date_from..=date_to).step_by(Days(2)) (or something like that) then I'm a happy man. :smile:

view this post on Zulip Aurélien Geron (Aug 16 2026 at 20:56):

As @Jasper Woudenberg said, 5..<2 looks a bit weird, but that's probably not an issue in practice (I guess we could allow 5..>2 but it might cause other problems, such as what to write when iterating between a and b and we don't know ahead of time which is bigger).

view this post on Zulip Richard Feldman (Aug 16 2026 at 21:13):

I wonder if that's an argument for the two syntaxes being 5..2 and 5..=2 :thinking:

view this post on Zulip Richard Feldman (Aug 16 2026 at 21:13):

the argument for 2..<5 is that it's more self-descriptive, but it's actively confusing in the case of 5..<2 it's actively confusing, which feels worse if that's going to be a common way people use ranges (and which is certainly supported, regardless of how commonly it is used)

view this post on Zulip Richard Feldman (Aug 16 2026 at 21:14):

obviously if you know that both 5..2 and 5..=2 exist, then you can re-derive which is inclusive and which is exclusive if you forget

view this post on Zulip Aurélien Geron (Aug 16 2026 at 23:12):

Actually, I just realized that there's a fairly common pattern where 5..<2 would be surprising. For example:

factorial = |n| {
    var $result = 1
    for i in 2..<n {
        $result = $result * i
    }
    $result
}

I realize that there are much simpler implementations, but bear with me. Today, factorial(1) correctly returns 1. If we let 2..<1 mean "from 2 down to 1 (excluded)", then the result will become 2.

My point is that users might expect a..<b to do nothing at all if ab.

view this post on Zulip Richard Feldman (Aug 16 2026 at 23:14):

that is a good point

view this post on Zulip Richard Feldman (Aug 16 2026 at 23:14):

I wonder how people feel about 2..n :thinking:

view this post on Zulip Richard Feldman (Aug 16 2026 at 23:14):

in that context

view this post on Zulip Aurélien Geron (Aug 16 2026 at 23:15):

Indeed, 2..n might make the user think twice in this case (which is a good thing). Or perhaps we should force the user to be explicit about the direction they expect, such as a..<b to go up, and a..>b to go down.

view this post on Zulip Matthieu Pizenberg (Aug 16 2026 at 23:20):

2..n looks totally fine to me

view this post on Zulip Jasper Woudenberg (Aug 17 2026 at 06:00):

I like .. and ..=, seems clear to me!

view this post on Zulip Luke Boswell (Aug 17 2026 at 06:03):

I feel like having a special syntax for this is less clear than something like

Range.({ from: 1, to: 10, by: 2})

view this post on Zulip Luke Boswell (Aug 17 2026 at 06:06):

Jasper Woudenberg said:

Alternative API design using records and optional fields:

Range.iter({ start: Inclusive(4), end: Exclusive(5), step: 2 })

I guess this is my favourite suggestion -- sorry I haven't been following this thread closely

view this post on Zulip Aurélien Geron (Aug 17 2026 at 10:24):

My first reaction was "wow that's way too long" for something so common. But I guess optional fields would make this shorter. For the most common usage, from 0 to n, it would look like this (assuming default start is Inclusive(0), and default step is 1:

for i in Range.iter({ end: Exclusive(5) }) { ... }

I guess I could live with that, but I much prefer 0..5 however, tbh.

view this post on Zulip Aurélien Geron (Aug 17 2026 at 10:31):

FWIW, the Exercism exercises contain 38 ranges, exactly half ..< and half ..=. Almost all the ..< start at 0 (16 out of 19). The ..= are more varied, for example 'A'..='Z'. Only 2 step_by and 1 iter_rev.

For reference, there are 284 match, 87 .map(...), 80 ??, 62 %, 27 { ..rec, ...}. So ranges are reasonably frequent.

view this post on Zulip Eric Rogstad (Aug 17 2026 at 17:00):

Richard Feldman said:

I wonder how people feel about 2..n :thinking:

Are we saying this always means 2 up to n (exclusive), or it can mean up to or down to depending on n?

If you leave off the <, doesn't this have the problem that @Aurélien Geron was pointing out where users would expect it to do nothing if 2 >= n?

Not sure if I'm missing something in terms of what's being proposed, but I think having a..b flip direction based on the values of a and b (and also possibly based on whether or not they can be evaluated at compile time) is inviting bugs.

But you could have:

I'd vote for that. (My second favorite would be the same thing but with a..<b and a..<=b for the first two options.)

To me this seems like it solves the problem of a..b flipping meaning based on the values, while still providing convenient syntax for both forward and reverse directions.

view this post on Zulip Kasper Møller Andersen (Aug 17 2026 at 17:37):

If a range of numbers is defined such that from is greater than to, you could also reasonably expect to generate a range that goes up to the highest number of the type, overflows, and then hits to, without ever descending. I would rather just use a reverse function than try to make sensible syntax for this.

view this post on Zulip Richard Feldman (Aug 17 2026 at 17:48):

I want to keep the range syntax so we can use it in pattern matching

view this post on Zulip Richard Feldman (Aug 17 2026 at 17:50):

not saying exactly what that syntax should be, just that it's useful beyond creating ranges - e.g. for matching http status codes 400..<500 =>

view this post on Zulip Kasper Møller Andersen (Aug 17 2026 at 19:17):

Would the descending range be needed for that? Just defining that range syntax always produces an ascending range seems like a good simplicification, and then anyone wanting to create a descending one can call reverse on it.

view this post on Zulip Jasper Woudenberg (Aug 17 2026 at 20:35):

That's pretty much the change announced at the start of this thread, downside is that it makes things a more complicated in other ways. The tldr; is that reverse() isn't defined on Iter anymore (because those are not as a rule all reversible), and so being able to define reverse() would require the range syntax to produce an intermediate Range type that defines reverse() along with iter().

view this post on Zulip Jasper Woudenberg (Aug 17 2026 at 20:42):

Eric Rogstad said:

Not sure if I'm missing something in terms of what's being proposed, but I think having a..b flip direction based on the values of a and b (and also possibly based on whether or not they can be evaluated at compile time) is inviting bugs.

I can imagine situations where the programmer intends to create an ascending range 2..n without realizing that for some values of n that would descend. But wouldn't in such cases the current behavior of returning an empty iterator most likely also be a bug?

view this post on Zulip Jasper Woudenberg (Aug 17 2026 at 20:45):

Richard Feldman said:

I want to keep the range syntax so we can use it in pattern matching

:heart_eyes: this would be really cool!

Would this still be achievable if range syntax goes back to directly producing an Iter instead of an intermediate Range type?

view this post on Zulip Richard Feldman (Aug 17 2026 at 20:49):

I think the syntax would mean something different in the case of patterns regardless

view this post on Zulip Richard Feldman (Aug 17 2026 at 20:50):

also, I think it's definitely clear that we should not have Iter.rev - so I think the open question here is what range syntax should do in the non-pattern scenario :smile:

view this post on Zulip Aurélien Geron (Aug 17 2026 at 21:37):

I'd also vote for @Eric Rogstad 's proposal (the second option because it's more explicit): 1..<10, 1..<=10, 10..>1, 10..>1. These force the user to choose the direction, which is a good thing IMO. As for 10..<1, I vote for an empty iterator. That's the standard behavior in Rust, Kotlin, Ruby, Python, Haskell, F#, Julia, Matlab, and others, and I never had any issue with it.

That said, since descending ranges and ranges with step ≠ 1 are relatively uncommon (~8% of all ranges), perhaps we only need a special syntax for ascending-by-one ranges (i.e., ..< and ..=) and the rest can be handled using a clear-but-long function call such as Range.iter({ start: Inclusive(10), end: Exclusive(0), step: 2 }). Note:

view this post on Zulip Eric Rogstad (Aug 17 2026 at 23:18):

Jasper Woudenberg said:

I can imagine situations where the programmer intends to create an ascending range 2..n without realizing that for some values of n that would descend. But wouldn't in such cases the current behavior of returning an empty iterator most likely also be a bug?

Can you give an example of where it would be a bug?

To me this seems like equivalent to writing a loop where the end condition is already true. Definitely a bug if the end condition can never be true, but I think there are lots of normal cases where sometimes when you encounter the loop the end condition is already true and sometimes it's not. (For a concrete example of that kind of case, see the factorial example that @Aurélien Geron shared above.)

view this post on Zulip Eric Rogstad (Aug 17 2026 at 23:21):

Aurélien Geron said:

That said, since descending ranges and ranges with step ≠ 1 are relatively uncommon (~8% of all ranges)

That's part of my motivation for preferring a..b and a..=b over a..<b and a..<=b. You just make the common case as simple as possible. And then you have to be a tiny bit more explicit if you want to do something weird (e.g. a..>b and a..>=b).

view this post on Zulip Eric Rogstad (Aug 17 2026 at 23:38):

Aurélien Geron said:

perhaps we only need a special syntax for ascending-by-one ranges (i.e., ..< and ..=) and the rest can be handled using a clear-but-long function call such as Range.iter({ start: Inclusive(10), end: Exclusive(0), step: 2 })

I think a major downside of this kind of syntax is that while technically it's "clear", it's pretty hard to read at a glance (at least for me).

I'd much prefer to look at something like (0..10).step(2). Maybe there's a little more up-front cost of understanding the syntax the very first time you come across it, but then there's less cost every other time you read that kind of code. Vs the longer-more-explicit syntax, which would feel (to me) more effortful to read every time.

view this post on Zulip Aurélien Geron (Aug 17 2026 at 23:51):

I'm happy with both .. and ..<. My slight preference for ..< is that it's more explicit, plus it's not actually more frequent than ..= (at least in Exercism exercises).

view this post on Zulip Aurélien Geron (Aug 17 2026 at 23:53):

I fully agree that readability matters. It's one of the reasons why I prefer the new Roc syntax with static dispatch, it makes the code more concise and (to me) easier to read.

view this post on Zulip Jasper Woudenberg (Aug 18 2026 at 06:20):

If I accept for the moment that the iterator syntax should have a clearly defined direction as a correctness thing, then it seems incongruent to me that there isn't some sort of error if the user passes an end value that comes before the start value.

view this post on Zulip Eric Rogstad (Aug 18 2026 at 07:51):

Jasper Woudenberg said:

If I accept for the moment that the iterator syntax should have a clearly defined direction as a correctness thing, then it seems incongruent to me that there isn't some sort of error if the user passes an end value that comes before the start value.

Should this give you an error in C++ if you call it when n is zero?

for (int i = 0; i < n; i++) {
  cout << i << "\n";
}

Or should you not be able to implement factorial this way in Roc?

factorial = |n| {
    var $result = 1
    for i in 2..<n {
        $result = $result * i
    }
    $result
}

view this post on Zulip Jasper Woudenberg (Aug 18 2026 at 19:40):

Eric Rogstad said:

Or should you not be able to implement factorial this way in Roc?

I don't think so, no, because the for-loop explicitly defines what it does. The range syntax is more compact and ambiguous. I don't immediately think of a for-loop when I see a range.

Eric Rogstad said:

Should this give you an error in C++ if you call it when n is zero?

I don't have super strong feelings about that particular implementation of factorial. It's using the fact that 2..<n is empty for n <= 3 in a neat way, you wouldn't be able to do that anymore with descending ranges. I think it could be adapted by writing ..<(n.lowest(3)) instead, seems fine to me too? It's a bit more explicit about how it handles the smaller numbers.


Last updated: Sep 03 2026 at 15:16 UTC