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 rangeclamp(value)— Snap value to the closest boundary if out of boundsisValueBefore(value)— True if value is before the low boundaryisValueAfter(value)— True if value is after the high boundarytype(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 stepstep(n, unit)— New range with unit-based stepasc()— Force ascending (empty if already descending)desc()— Force descending (empty if already ascending)isEmpty()— True if iteration would produce zero elementsisAscending()— True if step is positiveisIterable()— True if the range can be iteratedisBounded()— True if both start and end are presentisUnbounded()— True if both start and end are absentisHalfBounded()— True if exactly one bound is presenthasFrom()/hasTo()— Check individual boundsgetFrom()/getTo()— Get bound valuesgetStep()— Get current step valuetoArray()— Materialize to array (requires bounded + iterable)stream()— Get a Java Stream for functional pipelinestoString()— Human-readable representationequals(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.