BoxLang Ranges: Supercharged

BoxLang Ranges: Supercharged

The features covered here are in the 1.14-snapshot builds

Ranges in BoxLang just got a massive upgrade. Previously, the .. operator created a simple closed integer range that was immediately materialized into an array. No intermediate object, no lazy iteration, no member methods, no type support beyond integers, no unbounded ranges, no exclusive boundaries.

That’s all changed. The range operator now produces a first-class Range object that supports lazy iteration, streaming, multiple types, custom stepping, boundary queries, contains checks, and a full fluent API. Let’s walk through everything.

The Basics: Range as a First-Class Object

myRange = 1..5;
// Range object — NOT immediately an array

Ranges are iterable and seamlessly coerce to arrays when needed:

result = arrayToList( 1..5, "," );  // "1,2,3,4,5"
result = arrayLen( 1..10 );          // 10

But they’re NOT arrays — they’re lightweight objects that generate values on demand.

Supported Types

Integers and Decimals

1..5             // 1, 2, 3, 4, 5
3.5..1.5         // 3.5, 2.5, 1.5 (descending auto-detected)
(0..1).step(0.25) // 0, 0.25, 0.50, 0.75, 1.00

Characters

Single-character strings produce character ranges:

result = [];
for( c in "a".."e" ) {
    result.append( c );
}
// ["a", "b", "c", "d", "e"]
// Descending
for( c in "z".."v" ) { ... }
// z, y, x, w, v

DateTime

DateTime ranges iterate by day and support unit-based stepping:

start = createDate( 2024, 1, 1 );
end = createDate( 2024, 1, 5 );
r = start..end;
arrayLen( r )  // 5

// Step by month
r = (createDate(2024,1,15)..createDate(2024,6,15)).step(1, "month");
arrayLen( r )  // 6

// Step by week
r = (createDate(2024,1,1)..createDate(2024,1,29)).step(1, "week");
arrayLen( r )  // 5 (Jan 1, 8, 15, 22, 29)

// Contains with string dates
r = createDate(2024,1,1)..createDate(2024,1,31);
r.contains( "2024-01-15" )  // true
r.contains( "2024-02-01" )  // false

Any Comparable Type (Contains-Only)

Any two values of the same type that are naturally comparable can form a range — even if there’s no way to iterate between them. Multi-character strings are a good example: they have a well-defined lexicographic ordering, so contains() works, but there’s no natural “next” value after "foo":

r = "aaa".."zzz";

r.contains( "foo" )    // true — lexicographically between "aaa" and "zzz"
r.contains( "hello" )  // true
r.contains( "aaa" )    // true (inclusive boundary)
r.contains( "zzz" )    // true (inclusive boundary)
r.contains( "000" )    // false — comes before "aaa"

r.isIterable()  // false — can't step from "aaa" to "aab" automatically
r.isBounded()   // true

This works with any Java class that implements Comparable — including JDK types:

import java.time.Duration;

// A range representing "between 5 minutes and 2 hours"
r = Duration.ofMinutes( 5 )..Duration.ofHours( 2 );

r.contains( Duration.ofMinutes( 30 ) )  // true
r.contains( Duration.ofHours( 3 ) )     // false — exceeds 2 hours
r.contains( Duration.ofMinutes( 5 ) )   // true (boundary)

r.isIterable()  // false

Attempting to iterate a non-iterable range throws an error:

for( s in "aaa".."zzz" ) { }  // ERROR: range is not iterable

This pattern works for any type with a natural ordering — use it as a bounds check without needing to enumerate values.

Custom Types via IRangeable

Any BoxLang class can participate in ranges by implementing the IRangeable interface. See Part 2 for complete examples with Roman Numerals, Musical Notes, and Fibonacci sequences.

Unbounded and Half-Bounded Ranges

1..      // open end — no upper bound
..5      // open start — no lower bound
..       // fully open — contains everything non-null

Half-bounded ranges are lazy — you can iterate them with a break condition:

result = [];
for( i in 1.. ) {
    result.append( i );
    if( i == 5 ) break;
}
// [1, 2, 3, 4, 5]

Open-start and fully-open ranges cannot be iterated (no starting point):

for( i in ..5 ) {}  // ERROR: not iterable
for( i in .. ) {}   // ERROR: not iterable

But they still support contains():

(1..).contains( 999 )       // true
(..5).contains( 3 )         // true
(..).contains( "anything" ) // true
(..).contains( null )       // false (null is never in any range)

Typed Unbounded Ranges

A fully unbounded range (..) contains everything non-null by default. Use .type() to constrain it to a specific type — leveraging BoxLang’s loose casting system:

(..).contains( "foo" )               // true (no type constraint)
(..).type("number").contains( "foo" ) // false (wrong type)
(..).type("number").contains( "5" )   // true (coercible to number)
(..).type("integer").contains( 5.5 )  // false (not a whole integer)
(..).type("integer").contains( "5" )  // true (coercible to integer)

Any BoxLang type name works — string, numeric, integer, boolean, date, array, struct, etc. For custom classes, the instanceof operator is used:

(..).type("Widget").contains( myWidget )  // true if myWidget instanceof Widget

For exact Java class matching with no coercion, pass a Class reference directly:

import java:java.lang.Number;
(..).type( Number ).contains( 42 )    // true — Integer is a Number
(..).type( Number ).contains( "5" )   // false — strict instanceof, no coercion

Exclusive Boundaries

Four boundary modes via operators:

1..5     // inclusive both: 1, 2, 3, 4, 5
1>..5    // exclude start: 2, 3, 4, 5
1..<5    // exclude end:   1, 2, 3, 4
1>..<5   // exclude both:  2, 3, 4

These affect both iteration and contains():

r = 1>..5;
r.contains( 1 )  // false
r.contains( 2 )  // true
r.contains( 5 )  // true

Custom Stepping

The step() method returns a new range (copy-on-write) with a custom step:

arrayToList( (1..10).step(2), "," )   // "1,3,5,7,9"
arrayToList( (10..1).step(-3), "," )  // "10,7,4,1"
arrayToList( (1..10).step(3), "," )   // "1,4,7,10"

Unit-based stepping for DateTime and custom IRangeable types:

(start..end).step( 1, "month" )
(start..end).step( 1, "week" )
(start..end).step( 1, "year" )

Lazy Iteration and Streaming

Ranges are never materialized into memory unless you ask. This means huge and even infinite ranges are cheap:

// This does NOT allocate 100 billion integers
for( i in 1..100_000_000_000 ) {
    result = i;
    break;  // instant
}

Full Java Stream API integration:

result = (1..100_000_000_000).stream().limit( 5 ).toList();
// [1, 2, 3, 4, 5]

result = (1..).stream().limit( 5 ).toList();
// [1, 2, 3, 4, 5]

Use map, filter, takeWhile, anyMatch, limit, and all other stream operations.

Contains Semantics

Simple ranges (step = 1, no unit): Bounds Check

r = 1..10;
r.contains( 5 )         // true
r.contains( 0 )         // false
r.contains( "5" )       // true (numeric string coerced)
r.contains( "hello" )   // false (incompatible type)
r.contains( null )      // false (always)

Stepped ranges (step > 1 or unit): Step-Reachability Check

When a range has a custom step, contains() verifies the value is actually reachable by the stepper — not just within bounds:

r = (1..10).step(3);  // produces: 1, 4, 7, 10
r.contains( 4 )   // true  (reachable)
r.contains( 5 )   // false (within bounds but NOT reachable)
r.contains( 11 )  // false (out of bounds)

This follows the Python/Kotlin convention where a stepped range represents a discrete set.

For half-bounded stepped ranges, the iteration stops once the target is exceeded — so this is safe and terminates:

(1..).step(3).contains( 7 )   // true (1, 4, 7 ✓)
(1..).step(3).contains( 5 )   // false (1, 4, 7 — passed 5 without hitting it)

Non-iterable ranges: Always Bounds Check

Ranges that cannot be iterated (no start value, or no stepper) always fall back to a simple bounds check:

r = "aaa".."zzz";   // not iterable
r.contains( "foo" ) // true (between "aaa" and "zzz" lexicographically)

Range-in-Range Contains

You can check if an entire range fits within another:

outer = 1..10;
outer.contains( 3..7 )    // true
outer.contains( 5..15 )   // false (exceeds high end)
outer.contains( 1>..10 )  // true (exclusive inner fits in inclusive outer)
outer.contains( .. )       // false (unbounded inner can't fit in bounded outer)

// Unbounded outer contains anything
(..).contains( 1..100 )   // true
(1..).contains( 5.. )     // true
(5..).contains( 1..3 )    // false (inner starts below outer)

Clamping Values

The clamp() method snaps a value to the closest boundary if it falls outside the range:

(1..10).clamp( 11 )   // 10 — above high, snapped down
(1..10).clamp( 0 )    // 1  — below low, snapped up
(1..10).clamp( 5 )    // 5  — within bounds, unchanged
(1..10).clamp( "7" )  // 7  — coerced and returned

Half-bounded ranges snap to the one bound that exists:

(5..).clamp( 2 )      // 5   — snapped up to low
(5..).clamp( 999 )    // 999 — no upper bound
(..10).clamp( 50 )    // 10  — snapped down to high
(..10).clamp( -100 )  // -100 — no lower bound

Fully unbounded ranges just type-check and return the value:

(..).clamp( 42 )                     // 42
(..).type("numeric").clamp( "5" )    // 5 (coerced)
(..).type("numeric").clamp( "foo" )  // ERROR — incompatible type

Position Checks

Check whether a value falls before or after a range without getting a full contains() answer:

r = 1..10;
r.isValueBefore( -3 )   // true — below the low bound
r.isValueBefore( 5 )    // false — inside the range
r.isValueAfter( 50 )    // true — above the high bound
r.isValueAfter( 5 )     // false — inside the range

Exclusive boundaries are respected:

r = 5>..10;
r.isValueBefore( 5 )    // true — 5 is excluded from the range

Incompatible types return false (same as contains()):

(1..10).isValueBefore( "foo" )  // false
('a'..'z').isValueAfter( 42 )   // false

Member Methods

  • contains(value) — Check if value (or inner range) is within this range
  • clamp(value) — Snap value to the closest boundary if out of bounds
  • isValueBefore(value) — True if value is before the low boundary
  • isValueAfter(value) — True if value is after the high boundary
  • type(typeName) — New range constrained to a BoxLang type (uses casters for coercion)
  • type(class) — New range constrained to an exact Java class (strict instanceof)
  • step(n) — New range with numeric step
  • step(n, unit) — New range with unit-based step
  • asc() — Force ascending (empty if already descending)
  • desc() — Force descending (empty if already ascending)
  • isEmpty() — True if iteration would produce zero elements
  • isAscending() — True if step is positive
  • isIterable() — True if the range can be iterated
  • isBounded() — True if both start and end are present
  • isUnbounded() — True if both start and end are absent
  • isHalfBounded() — True if exactly one bound is present
  • hasFrom() / hasTo() — Check individual bounds
  • getFrom() / getTo() — Get bound values
  • getStep() — Get current step value
  • toArray() — Materialize to array (requires bounded + iterable)
  • stream() — Get a Java Stream for functional pipelines
  • toString() — Human-readable representation
  • equals(other) — Structural equality (bounds, step, exclusivity)

Empty Ranges and Truthiness

Ranges are truthy if non-empty, falsy if empty:

if( 1..5 ) { ... }           // truthy
if( (1..5).step(-1) ) { ... } // falsy (positive range, negative step = empty)
if( (5..1).asc() ) { ... }   // falsy (descending range forced ascending = empty)
if( 1>..<1 ) { ... }         // falsy (exclude both endpoints of single value = empty)

Operator Precedence

Arithmetic binds tighter than the range operator:

1 + 3 .. 5 * 2  // evaluates as (1+3)..(5*2) = 4..10

You can use any expression as operands:

abs(-3)..abs(-7)                    // 3..7
getStart()..getEnd()                // function calls
s.low..s.high                       // struct access
arr[1]..arr[2]                      // array index
(x ?: 1)..(x ?: 5)                  // null coalescing
" 2 ".trim().." 6 ".trim()          // chained methods
(x ? 1 : 10)..(x ? 5 : 20)          // ternary

Copy-on-Write Semantics

All modifier methods return new Range instances — the original is never mutated:

original = 1..10;
stepped = original.step(3);
original.getStep()  // 1 (unchanged)
stepped.getStep()   // 3

Error Cases

These throw runtime errors:

[1,2]..[3,4]    // arrays can't form ranges
{a:1}..{b:2}    // structs can't form ranges
1.."hello"      // incompatible types
(() => 1)..(() => 2)  // closures can't form ranges

(..5).toArray()       // can't materialize without a start
("aaa".."zzz").toArray()  // not iterable (no stepper)
(1..10).step(5, "minutes") // unit stepping not supported on plain numbers

In Part 2, we explore the IRangeable interface with three complete examples — Roman Numerals, Musical Notes, and Fibonacci sequences — showing how to build custom rangeable types with unit-based stepping, coercion, and stream composition.