Blog

Luis Majano

June 11, 2026

Spread the word


Share your thoughts

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

MethodDescription
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

MethodDescription
getFrom() / getTo()Get the bound values
getStep()Get the current step value

Transformation Methods

MethodDescription
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

MethodDescription
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 by step positions
  • rangeCompare( other ) -- return negative/zero/positive like Java's compareTo()
  • rangeCoerce( val ) -- convert foreign values to your type for contains() checks
  • rangeStepFromUnit( amount, unit ) (optional) -- convert named units to numeric steps
  • rangeUnitStepper( 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


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.

Add Your Comment

Recent Entries

MatchBox and WebAssembly: Running BoxLang in the Browser and at the Edge

MatchBox and WebAssembly: Running BoxLang in the Browser and at the Edge

The MatchBox open beta is live at https://boxlang.ortusbooks.com/boxlang-framework/matchbox, and it brings something genuinely new to the BoxLang ecosystem: a path into WebAssembly.

That means BoxLang code can now move into browser applications, static-site deployments, edge runtimes, and WASI-style containers - without requiring a JVM. The feature is still beta, but the core direction is already useful: write BoxLang, compile it with MatchBox, and ship the generated WASM artifact to wherever a small portable runtime makes sense.

Jacob Beers
Jacob Beers
June 04, 2026
One Language, Every Runtime: BoxLang Expands Beyond the Server

One Language, Every Runtime: BoxLang Expands Beyond the Server

Discover how BoxLang’s multi-runtime architecture helps developers build beyond the server with support for serverless functions, desktop applications, CI/CD workflows, Java integrations, containers, runtime management, and more.

Maria Jose Herrera
Maria Jose Herrera
June 04, 2026