There is a moment in every language's evolution when a convenience syntax grows up and becomes something worth thinking about. For BoxLang, that moment arrived with ranges. We had always wanted to be able to do this in our CFML apps, and we finally can.
The .. operator was introduced a few versions back with a pure syntactical sugar to create an array of certain items. You used it, it materialized an array, and you moved on. Simple. Useful. Unremarkable. In 1.14.0, that changes entirely. Ranges are now first-class interval objects: lazy, typed, extensible, and integrated with the full Java Stream API. They are no longer just a loop shortcut. They are a modeling primitive.
This post walks through everything that's new, and since there is a lot to cover, we have broken this into two parts. So welcome to part 1.
The Anatomy of a Range
Before diving into features, here is the conceptual shape of what a Range object now represents:
┌─────────────────────────────────────────────────────────────────┐
│ Range Object │
├──────────────┬──────────────────────────────────────────────────┤
│ Boundaries │ from (optional) .. to (optional) │
│ │ inclusive / exclusive per side │
├──────────────┼──────────────────────────────────────────────────┤
│ Step │ numeric (default: 1) or unit-based │
├──────────────┼──────────────────────────────────────────────────┤
│ Type │ integer, decimal, char, DateTime, IRangeable, │
│ │ or any java.lang.Comparable │
├──────────────┼──────────────────────────────────────────────────┤
│ Evaluation │ LAZY - values generated on demand │
├──────────────┼──────────────────────────────────────────────────┤
│ API │ contains, clamp, step, type, asc, desc, │
│ │ stream, toArray, isValueBefore, isValueAfter... │
└──────────────┴──────────────────────────────────────────────────┘
A Range is never an array. It never allocates memory for its values. It knows where it starts, where it ends (or if it ends at all), how to step, and how to hand values to a consumer one at a time.
The Basics: Range as a First-Class Object
myRange = 1..5
// Range object -- NOT immediately an array
Ranges coerce to arrays transparently wherever BoxLang expects a collection:
result = arrayToList( 1..5, "," ) // "1,2,3,4,5"
println( result )
result = arrayLen( 1..10 ) // 10
println( result )
But they are not arrays. That distinction matters the moment you work with large or infinite sequences -- which we will get to shortly.
Supported Types
Integers and Decimals
Integer ranges detect direction automatically. No need to specify ascending or descending:
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, ascending or descending:
result = []
for( c in "a".."e" ) {
result.append( c )
}
// [ "a", "b", "c", "d", "e" ]
// Descending
for( c in "z".."v" ) {
writeOutput( c & " " )
}
// z y x w v
DateTime
DateTime ranges iterate by day by default. Unit-based stepping unlocks month, week, and year intervals:
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)
// String date coercion works in contains()
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 implement java.lang.Comparable can form a range, even when iteration is not meaningful. Multi-character strings are the clearest example: lexicographic ordering is well-defined, but there is no natural "next" string after "foo".
r = "aaa".."zzz"
r.contains( "foo" ) // true -- lexicographically between the bounds
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
r.isBounded() // true
This extends to any JDK type:
import java.time.Duration
r = Duration.ofMinutes( 5 )..Duration.ofHours( 2 )
r.contains( Duration.ofMinutes( 30 ) ) // true
r.contains( Duration.ofHours( 3 ) ) // false
r.contains( Duration.ofMinutes( 5 ) ) // true (inclusive boundary)
r.isIterable() // false
Attempting to iterate a non-iterable range throws a runtime error immediately:
for( s in "aaa".."zzz" ) { } // ERROR: range is not iterable
Use comparable-only ranges as bounds checks, not as loops.
Unbounded and Half-Bounded Ranges
Ranges can be open on one end, both ends, or neither:
..5 open start -- no lower bound, not iterable
1.. open end -- no upper bound, iterable with break
.. fully open -- contains everything non-null, not iterable
Half-bounded ranges with a defined start are lazy and fully iterable -- just provide your own exit 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 iterate (no starting point exists), but they 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 matches everything non-null by default. Use .type() to constrain it to a specific BoxLang type, leveraging the full casting system:
( .. ).contains( "foo" ) // true (no 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)
For custom classes, instanceof is used:
( .. ).type( "Widget" ).contains( myWidget ) // true if myWidget instanceof Widget
For strict Java class matching with no coercion, pass a Class reference:
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 are available via operator syntax:
1..5 // inclusive both ends: 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
Exclusivity affects both iteration and contains():
r = 1>..5
r.contains( 1 ) // false
r.contains( 2 ) // true
r.contains( 5 ) // true
Custom Stepping
step() returns a new Range with a different step increment. The original is never mutated (more on that under Copy-on-Write):
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 Java Stream Integration
This is where ranges separate themselves from every array-backed alternative. Ranges never allocate memory for their values. The iteration is fully lazy -- values are produced one at a time, on demand.
// This does NOT allocate 100 billion integers in memory
for( i in 1..100_000_000_000 ) {
result = i
break // instant
}
The same lazyness is available through the full Java Stream API:
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 ]
Any stream operation works: map, filter, takeWhile, anyMatch, limit, reduce, and everything else the Java Stream API provides. This makes ranges a natural fit for data processing pipelines where you want expressive, composable operations without intermediate allocations.
Contains Semantics
contains() has three distinct behaviors depending on the range:
Bounds Check (step = 1, no unit)
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)
Step-Reachability Check (step > 1 or unit-based)
When a range has a custom step, contains() verifies the value is actually reachable by the stepper -- not merely within bounds. This follows the Python/Kotlin convention where a stepped range represents a discrete set.
r = ( 1..10 ).step( 3 ) // produces: 1, 4, 7, 10
r.contains( 4 ) // true (reachable)
r.contains( 5 ) // false (within bounds but NOT reachable by step)
r.contains( 11 ) // false (out of bounds)
For half-bounded stepped ranges, the iteration terminates as soon as the target is exceeded, so this is always safe:
( 1.. ).step( 3 ).contains( 7 ) // true (1, 4, 7)
( 1.. ).step( 3 ).contains( 5 ) // false (1, 4, 7 -- passed 5 without a match)
Bounds Check (non-iterable ranges, always)
r = "aaa".."zzz" // not iterable
r.contains( "foo" ) // true (lexicographic bounds check)
Range-in-Range Contains
You can test whether 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 inside inclusive outer)
outer.contains( .. ) // false (unbounded inner cannot fit 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
clamp() snaps a value to the nearest boundary when 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 whichever bound exists:
( 5.. ).clamp( 2 ) // 5 -- snapped up to low bound
( 5.. ).clamp( 999 ) // 999 -- no upper bound
( ..10 ).clamp( 50 ) // 10 -- snapped down to high bound
( ..10 ).clamp( -100 ) // -100 -- no lower bound
Fully unbounded ranges type-check and return the value as-is:
( .. ).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 the 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, so it falls "before" the range
Incompatible types return false, consistent with contains():
( 1..10 ).isValueBefore( "foo" ) // false
( "a".."z" ).isValueAfter( 42 ) // false
Member Methods Reference
Query Methods
| Method | Description |
|---|---|
contains( value ) | Check if a value (or inner range) is within this range |
isValueBefore( value ) | True if the value falls before the low boundary |
isValueAfter( value ) | True if the value falls after the high boundary |
isEmpty() | True if iteration would produce zero elements |
isAscending() | True if the 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 |
Accessors
| Method | Description |
|---|---|
getFrom() / getTo() | Get the bound values |
getStep() | Get the current step value |
Transformation Methods
| Method | Description |
|---|---|
clamp( value ) | Snap a value to the closest 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 a numeric step |
step( n, unit ) | New range with a unit-based step |
asc() | Force ascending direction (empty if range is already descending) |
desc() | Force descending direction (empty if range is already ascending) |
Conversion Methods
| Method | Description |
|---|---|
toArray() | Materialize to array (requires bounded and 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. This integrates naturally with BoxLang conditionals:
if( 1..5 ) { } // truthy
if( ( 1..5 ).step( -1 ) ) { } // falsy -- positive range with negative step = empty
if( ( 5..1 ).asc() ) { } // falsy -- descending range forced ascending = empty
if( 1>..<1 ) { } // falsy -- excluding both endpoints of a single value = empty
Operator Precedence and Expressions
Arithmetic binds tighter than the range operator:
1 + 3 .. 5 * 2 // (1+3)..(5*2) = 4..10
Any expression is valid as a range operand:
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 member methods
( x ? 1 : 10 )..( x ? 5 : 20 ) // ternary expressions
Copy-on-Write Semantics
Every transformation method -- step(), type(), asc(), desc() -- returns a new Range instance. The original is never modified:
original = 1..10
stepped = original.step( 3 )
original.getStep() // 1 (unchanged)
stepped.getStep() // 3
This makes ranges safe to share, transform, and pass around without defensive copying.
Error Cases
These all throw runtime errors with clear messages:
[ 1, 2 ]..[ 3, 4 ] // arrays cannot form ranges
{ a: 1 }..{ b: 2 } // structs cannot form ranges
1.."hello" // incompatible types
( () => 1 )..( () => 2 ) // closures cannot form ranges
( ..5 ).toArray() // cannot materialize without a start
( "aaa".."zzz" ).toArray() // not iterable (no stepper for strings)
( 1..10 ).step( 5, "minutes" ) // unit stepping not supported for plain numbers
Custom Types: The IRangeable Interface
The features above cover the built-in types. But the most powerful capability in 1.14.0 is the IRangeable interface, which lets any BoxLang class participate in the range system.
Implement ortus.boxlang.runtime.types.IRangeable and provide:
rangeAdvance( step )-- return a new instance advanced bysteppositionsrangeCompare( other )-- return negative/zero/positive like Java'scompareTo()rangeCoerce( val )-- convert foreign values to your type forcontains()checksrangeStepFromUnit( amount, unit )(optional) -- convert named units to numeric stepsrangeUnitStepper( unit )(optional) -- return a closure for non-uniform stepping
This opens up ranges over Roman numerals, musical notes, Fibonacci sequences, software version numbers, fiscal periods, geographic coordinates, workflow states -- any type with a logical progression. The range system becomes an extensible framework, not just a language feature.
A complete deep-dive into IRangeable -- including three full working implementations (Roman Numerals, Musical Notes with scale stepping, and Fibonacci sequences) -- is covered in Part 2.
Wrapping Up
BoxLang 1.14.0 takes ranges from "convenient array shortcut" to "first-class interval type." The key properties that make them worth reaching for:
- Lazy -- no memory allocated until you consume values
- Multi-type -- integers, decimals, characters, DateTime, any Comparable, custom IRangeable
- Composable -- copy-on-write methods chain cleanly; Java Stream integration is seamless
- Expressive -- exclusive boundaries, unbounded ends, typed constraints, unit stepping
- Predictable -- step-reachability semantics for contains(), clear error cases, truthiness support
The .. operator is no longer just a loop shortcut. Use it.
References
- BoxLang Ranges Documentation
- BoxLang 1.14.0 Release Notes
- BoxLang Ranges Part 2: Custom Types with IRangeable
About Ortus Solutions
Ortus Solutions is the company behind BoxLang, ColdBox, CommandBox, and 350+ open source libraries. We build tools that help developers write better software faster.
- BoxLang -- boxlang.io | Plans & Pricing
- ForgeBox -- forgebox.io
- Community -- community.ortussolutions.com
- Follow us -- @ortussolutions
Add Your Comment