Stream: ideas

Topic: range syntax/type


view this post on Zulip Richard Feldman (Jun 08 2026 at 18:15):

so I realized some unfortunate problems with our current 1.to(5) syntax:

view this post on Zulip Richard Feldman (Jun 08 2026 at 18:30):

idea for a way to fix for all of these: just actually do numeric range syntax, and have it return a Range. so inclusive range looks like Rust's:

1..=5

...and so does inclusive:

1..5

the exclusive one would return a value of type:

Iter(num) where [
    num.from_literal : ...,
    num.plus : ...,
    num.lt : ...
]

view this post on Zulip Richard Feldman (Jun 08 2026 at 18:32):

...which in turn would naturally unify the way we want, and then everything would work from there

view this post on Zulip Richard Feldman (Jun 08 2026 at 18:38):

and then of course we have an obvious range syntax for pattern matching

view this post on Zulip Nick Gravgaard (Jun 09 2026 at 19:17):

Regarding naming, Scala has to and until methods which create inclusive and exclusive Range objects respectively:

scala> 1.to(5)
val res1: collection.immutable.Range.Inclusive = Range(1, 2, 3, 4, 5)

scala> 1.until(5)
val res2: Range = Range(1, 2, 3, 4)

For me, until is obviously exclusive, but to is a bit less obviously inclusive

view this post on Zulip Richard Feldman (Jun 09 2026 at 19:17):

yeah I looked at that...until doesn't seem clear to me :sweat_smile:

view this post on Zulip Nick Gravgaard (Jun 09 2026 at 19:18):

lol. I wonder if Dijkstra wrote something about this

view this post on Zulip Nick Gravgaard (Jun 09 2026 at 19:20):

https://www.cs.utexas.edu/~EWD/transcriptions/EWD08xx/EWD831.html

view this post on Zulip Eric Rogstad (Jun 09 2026 at 19:24):

Richard Feldman said:

...and so does inclusive:

1..5

Just to confirm, this was supposed to say "exclusive", right?

view this post on Zulip Richard Feldman (Jun 09 2026 at 19:27):

yes, oops

view this post on Zulip Richard Feldman (Jun 09 2026 at 19:27):

fixed!

view this post on Zulip Tobias Steckenborn (Jun 09 2026 at 19:34):

I'd assume 1..5 and 1..=5 is as cryptic as "to" and "until". Both require prior knowledge :sweat_smile:

Given all of us should use modern ides with autocomplete (and given that would likely also be easier to pick up for ai) what about simply to_including or to_excluding? Or is that too long?

view this post on Zulip Richard Feldman (Jun 10 2026 at 05:05):

Tobias Steckenborn said:

I'd assume 1..5 and 1..=5 is as cryptic as "to" and "until". Both require prior knowledge :sweat_smile:

I was going to say that these are pretty standardized because so many programming languages have them, but I just researched it a bit and it turns out the syntax is standard but inconsistent across languages as to whether .. means inclusive or exclusive :facepalm:

view this post on Zulip Richard Feldman (Jun 10 2026 at 05:06):

I think ..= is clearly inclusive, and interestingly Swift uses ..< for exclusive

I don't really love how either of those look, but if you know both of them, I do like how self-descriptive they are

view this post on Zulip Luke Boswell (Jun 10 2026 at 05:10):

fwiw, they look cryptic to me (not against this or anything, just not familiar with this syntax)

view this post on Zulip Luke Boswell (Jun 10 2026 at 05:10):

I would have assumed 1..5 == 1,2,3,4,5

view this post on Zulip Luke Boswell (Jun 10 2026 at 05:11):

Is there a specific reason to have both inclusive and exclusive?

view this post on Zulip Luke Boswell (Jun 10 2026 at 05:11):

Our last range discussion I can think about was a long time ago and we also talked about steps and direction too

view this post on Zulip Tobias Steckenborn (Jun 10 2026 at 05:47):

having a sort of configuration object with defaults is not in line with what's done everywhere else, right?

So e.g.:

1.to(5)

being implicit for something like

1.to(5, {mode: "exclusive", stepSize: 1 [...]})

view this post on Zulip Richard Feldman (Jun 10 2026 at 05:52):

to be clear, we have a types problem with 1.to(5) which means we need to move away from it.

For example, @Aurélien Geron immediately ran into it in practice in exercism examples - it was a cool idea but it just gives type mismatches instead of doing what you want in too many real-world programs.

view this post on Zulip Richard Feldman (Jun 10 2026 at 05:53):

so the decision has to be about what syntax to use instead of the plain-method one that looks cool but doesn't work well in practice :smile:

view this post on Zulip Romain Lepert (Jun 10 2026 at 08:56):

Luke Boswell said:

Is there a specific reason to have both inclusive and exclusive?

Exclusive range is the most important one for languages with 0-based indexing because the most common pattern is:

a = ["foo", "bar"]
for i in 0..a.len() {
    # TODO
}

I believe inclusive range is less useful but convenient when the last value is meaningful

for month in 0..=12 {
    # TODO
}

Our last range discussion I can think about was a long time ago and we also talked about steps and direction too

rust does

for i in (0..10).rev().step_by(2) {
  # TODO
}

Python does it with <start>:<end>:<step> "slice" syntax (e.g. 0:10:-2) which is quite common for indexing ndarray:

a = np.zeros((10, 10))
a[0, :] == a[0, 0:10:1] == a[0, 0:10] == a[0, 0:] == a[0, :10]  # first row, all columns
a[0, ::2] == a[0, 0:10:2] # first row, even columns
a[0, ::-1]  # first row flipped

python also provides a alternative slice(start, end, stop) function for constructing slices.

python provides a range(start, end, stop) standalone function which is the iterator version.

view this post on Zulip Prokop Randacek (Jun 10 2026 at 09:34):

Note that from lexical perspective, using .. (and ...) as a token for integer ranges is the only place in zig grammar that requires more than single character lookahead. Consider having lexed 0 and seeing a .. Is this a float literal or start of an integer range? (0.0 vs 0..3). The zig lexer currently guesses that it is a float literal and if it fails, it backtracks. It is the only place that requires this special treatment. (https://ziggit.dev/t/300-mib-s-zig-lexer-fixing-an-edge-case-in-the-grammar/9514)

Therefore if the decision is between 0..5 and 0:5 I would be for the colon since i find them equivalent in readability :D

view this post on Zulip Anton (Jun 10 2026 at 11:53):

Richard Feldman said:

I think ..= is clearly inclusive, and interestingly Swift uses ..< for exclusive

I don't really love how either of those look, but if you know both of them, I do like how self-descriptive they are

I like these.

view this post on Zulip Richard Feldman (Jun 10 2026 at 12:24):

we can't use a : b because that's already a type annotation :smile:

view this post on Zulip Norbert Hajagos (Jun 10 2026 at 15:57):

Nick Gravgaard said:

https://www.cs.utexas.edu/~EWD/transcriptions/EWD08xx/EWD831.html

Nice article! I agree. Inclusive ranges aren't as useful, so maybe it's not worth addig a syntax for them.

view this post on Zulip Matthieu Pizenberg (Jun 10 2026 at 17:55):

also agree with the fact that semi-exclusive ranges are much more useful in maths and matrix/vectors manipulation. Inclusive lower bound, paired with exclusive higher bound. Also very convenient for everything related to versioning and dependency solving. Though for the latter, having both inclusive and exclusive bounds is very useful. See for example the version_ranges package from the pubgrub dependency solver: https://github.com/pubgrub-rs/pubgrub/blob/dev/version-ranges/src/lib.rs

view this post on Zulip Richard Feldman (Jun 10 2026 at 18:41):

yeah I don't think "only do exclusive" is the answer

view this post on Zulip Arya Elfren (Jun 13 2026 at 22:16):

Norbert Hajagos said:

Inclusive ranges aren't as useful, so maybe it's not worth addig a syntax for them.

They should still be representable. It would be annoying to not be able to represent a loop/range over every valid value of an integer type. Would you have to write 0..maxint + 1? Would that overflow?

view this post on Zulip Norbert Hajagos (Jun 14 2026 at 11:19):

Richard Feldman said:

yeah I don't think "only do exclusive" is the answer

This decided it, but I'll answer with saying that you can still construct a range manually and for such an extreme case, it wouldn't be that strange to reach for a slightly less convenient approach.
But the article doesn't take pattern matching into account, which is where I can see this being useful. I still think guard clauses would suffice, but I can see the appeal of something like:

match char {
    'A'..'Z' => ...
}

view this post on Zulip Norbert Hajagos (Jun 14 2026 at 11:21):

Prokop Randacek said:

Note that from lexical perspective, using .. (and ...) as a token for integer ranges is the only place in zig grammar that requires more than single character lookahead. Consider having lexed 0 and seeing a .. Is this a float literal or start of an integer range? (0.0 vs 0..3). The zig lexer currently guesses that it is a float literal and if it fails, it backtracks. It is the only place that requires this special treatment. (https://ziggit.dev/t/300-mib-s-zig-lexer-fixing-an-edge-case-in-the-grammar/9514)

Therefore if the decision is between 0..5 and 0:5 I would be for the colon since i find them equivalent in readability :D

We already have ... as a meaningful syntax, so that already applied to us.
But it's a really cool thing, well worth it.

view this post on Zulip Kasper Møller Andersen (Jun 15 2026 at 21:05):

If we’re looking for potential names, I would definitely consider through for the inclusive case (i.e. the broken syntax would be 1.through(5))

view this post on Zulip Jared Ramirez (Jun 16 2026 at 00:33):

fyi this landed! (syntax desugaring to iter, no range pattern matching atm) #9611

view this post on Zulip Aurélien Geron (Jun 16 2026 at 01:46):

That's great! Clear syntax, covers most use cases.
Just wondering about step size and infinite ranges.
What's the right expression for:

view this post on Zulip Richard Feldman (Jun 16 2026 at 01:53):

step is interesting - if we want to support that, we could make an actual Range type with an iter() method (so it continues to Just Work in for loops) instead of returning Iter directly

view this post on Zulip Richard Feldman (Jun 16 2026 at 01:53):

we could also support 0.. as "to infinity" too :thumbs_up:

view this post on Zulip Jared Ramirez (Jun 16 2026 at 02:28):

i had the same thought when implementing and looked af what rust does, and they have a step_by func on iter (https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.step_by)

if we do the same in roc, it would look like 1..=10.step_by(2)

view this post on Zulip Richard Feldman (Jun 16 2026 at 03:24):

sounds reasonable!

view this post on Zulip Aurélien Geron (Jun 16 2026 at 05:17):

4..=1 would be nice for 4, 3, 2, 1.
However, 4..<0 would be really weird. 4..0 or 4..>0 would make more sense.

view this post on Zulip Jared Ramirez (Jun 16 2026 at 20:45):

step_by & rev landed too: https://github.com/roc-lang/roc/pull/9669

view this post on Zulip Jamie Neubert Pedersen (Jul 05 2026 at 05:34):

I still don't understand why we to can't automagically make the 1 be a known integer type?

view this post on Zulip Anton (Jul 05 2026 at 12:02):

Hi @Jamie Neubert Pedersen,
I believe you mean that you would like the code below to work, is that right?

» double : U64 -> U64
» double = |x| x * 2
assigned `double`
» 1.to(3).map(double)

┌───────────────┐
│ TYPE MISMATCH ├─ The map method on Iter has an incompatible type. ────────────────────────────────────────┐
└┬──────────────┘                                                                                           │
 │                                                                                                          │
 │  main = 1.to(3).map(double)                                                                              │
 │         ‾‾‾‾‾‾‾                                                                                          │
 └──────────────────────────────────────────────────────────────────────────────────────────────── repl:5:8 ┘

    The method map has the type:

        Iter(Dec), (Dec -> b) -> Iter(b)

    But I need it to have the type:

        Iter(Dec), (U64 -> U64) -> _ret

view this post on Zulip Anton (Jul 05 2026 at 12:16):

I was a little surprised that did not work as well, here is Claude Fable's report on it (using the source code of PR#9944):

What's happening

There's no fundamental reason it can't work — the failure is a specific, fixable gap in how far the numeral-defaulting probe looks, not a design impossibility.

The chain of events for 1.to(3).map(double):

  1. Because to is a method, its signature is unknown until the receiver's concrete type is known (each numeric type has its own to : U64, U64 -> Iter(U64) etc. in Builtin.roc). So unification can't flow U64 backwards from double into the literal — the link between the literal and the Iter's element type doesn't exist in the constraint graph until to dispatches, and to can't dispatch until the literal is pinned. The only mechanism that can break this cycle is the candidate probe in defaulting.
  2. The probe (commitLiteralDefault → candidateSatisfiesRangeConstraints in src/check/Check.zig:15650) checks each candidate (Dec first) against the constraints directly attached to the literal var. That check does unify the full to signature — which is why pinning an argument works: 1.to(3.U64).map(double) correctly infers U64 today, I verified in the REPL.
  3. But the map constraint lives one hop away, on the flex var returned by .to(3). When the Dec probe unifies Dec.to : Dec, Dec -> Iter(Dec) against the call, that return var becomes Iter(Dec) and its map constraint is deferred, not checked in-probe. So Dec "satisfies" to, wins as first satisfier, commits — and only afterwards does the cascade (step 4 of runLiteralDefaultingRounds) fire map against Iter(Dec) and produce the mismatch you saw.

Notably, Iter.inclusive_range(1, 3).map(double) works today (verified) — because it's a plain generic function num, num -> Iter(num), ordinary unification pins num = U64 through the return type. That makes the method version's failure feel arbitrary from the user's side, which strengthens the case for fixing it.

Could it be made to work?

Yes: run the deferred-dispatch cascade inside the commit-probe and treat any newly-fireable dispatch failure as refuting the candidate. Dec would be refuted (Iter(Dec).map rejects U64 -> U64), I64 likewise, and U64 becomes the first satisfier. The machinery seems built to accommodate this:

Reasons you might still not want it

My take: this is worth making work, likely scoped as "fire deferrebecame concrete during this probe's unifications, refute onfailure" — that's naturally bounded and covers the return-positionthe engineering care around the order-independence proof, not any
semantic obstacle. In the meantime, pinning any position works: 1.er.inclusive_range(1, 3).

view this post on Zulip Jonathan (Jul 05 2026 at 13:20):

Another vote for being surprised. Some of that is a bit in the weeds for me but the compile cost drawbacks strike me as remarkably similar to the problems with Swift. I did a little bit of reading into this post overriding/multiple dispatch discussion, and it was my understanding that slow times could often be attributed to a collision between Swift's protocols for parsing literals, and overloaded operators when annotations are not provided. To make something like 1.to(3).map(double) work, at least according to my shakey understanding of this report (and shakier understanding of type inference), you would need to permit a backtracking search over multiple steps. Would that not also collide with Roc's literal defaulting and resolution in the same way? Am I completely off base?

view this post on Zulip Anton (Jul 05 2026 at 14:16):

Excellent question @Jonathan! I asked Fable again :)

No — the proposal is not a backtracking search, and it structurally can't reproduce Swift's blowup. Swift's cost is exponential because it's a product over many independent choice points. The Roc proposal stays a sum over components of a flat, depth-1 candidate scan. The distinction is worth spelling out, because from a distance "try a candidate, roll back, try the next" does look like backtracking.

Why Swift explodes

Swift's solver has a choice point at nearly every node of the expression tree: 17 overloads of +, 9 types adopting ExpressibleByStringLiteral, plus bidirectional inference tying them all together. Each choice constrains the others, so the solver must explore combinations — pick an overload for this +, a type for that literal, see if the rest still solves, and when a downstream failure occurs, undo earlier choices and try different combinations of them. That's a search tree whose depth is the number of choice points and whose branching factor is the overload count: k₁ × k₂ × … × kₙ. The blog post's URL example fails slowly precisely because the error is unresolvable, so the solver grinds through the whole product space before giving up.

Why the Roc proposal doesn't

Three structural differences, in decreasing order of importance:

  1. Roc has exactly one kind of choice point, and they don't multiply. There is no ad-hoc overloading: every function has one type, every operator desugars to a single method, and method dispatch is driven by the receiver's concrete type — it waits for the receiver rather than enumerating candidate receivers. The only place where the checker ever entertains alternatives is a numeric/quote literal's default-candidate list. And the interference-component machinery ensures those don't combine: literals whose constraint footprints overlap are gathered into one component, and — this is what I just verified — the group probe tries the candidate list once for the whole group, first candidate satisfying all drivers jointly wins. So a component with 5 interfering literals costs at most 13 probes, not 13⁵. Components that don't interfere are independent by the order-independence proof, so total cost is a sum across components, never a product.

  2. Commitment is final — there is no undo-and-retry across choice points. The proposal extends how deep each probe looks before committing (run the deferred-dispatch cascade in-probe), but it doesn't change the search shape: a single linear scan of ≤13 alternatives, first satisfier commits, and a commit is never revisited. Swift's pathology requires the opposite — later failures reopening earlier decisions. Rollback-per-probe is speculative execution with a search tree of depth 1; backtracking pathologies need depth.

  3. The cascade inside a probe is deterministic — no nested choice points. This is the subtle one, and it's where your instinct was rightly probing. If the in-probe cascade could itself hit another unresolved literal and start enumerating its candidates, you'd have recursion of choice points and the product space would be back. But the design already forbids nested commit probes (commit_probe_active), and dispatch on a still-flex receiver simply stays deferred rather than branching. So inside a probe, everything is ordinary one-way unification: the candidate either propagates to a downstream failure or it doesn't. One deterministic pass, no branching.

The honest cost model

The real cost of the proposal is that each probe goes from "unify one method signature" to "speculatively typecheck the downstream chain." Worst case per component: 13 candidates × one pass over the method chain, and refuting Dec always pays full price since the structural pre-filter can't see transitive refutations. For a chain of length m that's O(13·m) — and even a pathological file where every literal drags a long chain lands around quadratic in expression size. Polynomial, boring, and — unlike Swift — predictable: cost is (component count) × (candidate list) × (chain length), all of which the programmer can see in the source. Swift's cost is a function of how the solver's heuristics happen to order an exponential space, which is why appending + 1 to an expression can change compile time by 10×. That unpredictability, more than the raw seconds, is what makes Swift's situation feel broken, and it's the property the Roc design keeps out by construction.

One Swift lesson that does carry over as a genuine caveat: the failure-is-the-slow-path asymmetry. In Swift, erroneous code is the worst case because the solver exhausts the space before reporting. In the Roc proposal there's a milder echo — code where no candidate satisfies pays for all 13 cascades and then still has to produce an error (presumably by falling back to today's commit-Dec-and-let-the-cascade-report behavior, so diagnostics don't regress). That's a constant factor of ~13 on the error path, not an exponential cliff, but it's the same shape of concern and worth keeping an eye on if the candidate list ever grows or probes ever get chained.

So when my earlier report flagged "compile cost," the worry was this constant-factor probe deepening on the hot path — a performance tax, not a tractability risk. Swift's problem isn't that its checker does speculative work; it's that speculation happens at every node and composes multiplicatively. Roc's defaulting confines speculation to one flat menu per component, and the proposal doesn't change that shape.


Last updated: Jul 23 2026 at 13:15 UTC