BoxLang Ranges Part 2: Custom Types with IRangeable
The features covered here are in the 1.14-snapshot builds
In Part 1, we covered all the built-in range features — types, boundaries, stepping, streaming, and contains semantics. Now let’s see how to make your own classes participate in ranges by implementing the IRangeable interface.
The IRangeable Interface
Any Java or BoxLang class can become rangeable by implementing ortus.boxlang.runtime.types.IRangeable. You need to provide:
rangeAdvance( step )— Return a new instance advanced bysteppositionsrangeCompare( other )— Compare to another instance (negative = less, 0 = equal, positive = greater)rangeCoerce( val )— Convert arbitrary values to your type forcontains()checksrangeStepFromUnit( amount, unit )— (Optional) Convert a unit-based step to a numeric steprangeUnitStepper( unit )— (Optional) Return a custom stepper closure for non-uniform stepping
Let’s build three complete examples.
Example 1: Fibonacci — Infinite Non-Linear Sequences
Not all sequences advance linearly. The Fibonacci sequence is a perfect example — each value depends on the previous two, so rangeAdvance(1) produces values that grow exponentially. BoxLang’s range system handles this naturally.
class Fib implements="java:ortus.boxlang.runtime.types.IRangeable" {
property name="prev" type="integer" default=0;
property name="current" type="integer" default=1;
// Advance by N steps in the Fibonacci sequence
function rangeAdvance( step ) {
var result = this;
for( var i = 1; i <= step; i++ ) {
result = new Fib(
prev: result.getCurrent(),
current: result.getPrev() + result.getCurrent()
);
}
return result;
}
// Compare by current value (defines ordering)
function rangeCompare( other ) {
return variables.current - other.getCurrent();
}
// Convert integers to Fib instances for contains() checks
function rangeCoerce( val ) {
if( val instanceof "Fib" ) return val;
if( isNumeric( val ) ) return new Fib( current: int(val) );
return null;
}
}
Notice what’s not here: no rangeStepFromUnit(), no rangeUnitStepper(). Fibonacci only needs the three core methods. The class carries two fields — prev and current — so each instance knows how to compute the next value.
Lazy Infinite Sequence
Since Fibonacci is infinite, we use a half-bounded range and stream operations to work with it safely:
// First 10 Fibonacci numbers
(new Fib()..).stream().limit(10).map( .getCurrent() ).toList();
// [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
// Every other Fibonacci number (step by 2)
(new Fib()..).step(2).stream().limit(5).map( .getCurrent() ).toList();
// [1, 2, 5, 13, 34]
Contains: Is This a Fibonacci Number?
Because Fib implements IRangeable, the range knows that rangeAdvance(1) isn’t a simple increment — it iterates through the actual sequence to check reachability:
(new Fib()..).contains( 13 ) // true — 13 is a Fibonacci number
(new Fib()..).contains( 14 ) // false — 14 is not
(new Fib()..).contains( 144 ) // true
(new Fib()..).contains( 150 ) // false
The rangeCoerce() method converts the plain integer 13 into new Fib( current: 13 ), then containsStepped() walks the sequence: 1, 1, 2, 3, 5, 8, 13 — match found.
For-In with Break
You can also use traditional iteration with an exit condition:
under100 = [];
for( f in new Fib().. ) {
if( f.getCurrent() > 100 ) break;
under100.append( f.getCurrent() );
}
// [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
Why This Works
The key insight is that IRangeable types always use step-reachability for contains() — the range iterates through actual values produced by rangeAdvance() rather than doing a simple bounds check. This is essential for sequences like Fibonacci where the “distance” between values is non-uniform.
Example 2: Roman Numerals
A Roman numeral class that stores an integer internally and converts to/from Roman notation. This demonstrates iteration, stepping, and coercion-based contains.
class Roman implements="java:ortus.boxlang.runtime.types.IRangeable" {
property name="value" type="integer" default=0;
static {
VALUES = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
SYMBOLS = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
}
// Construct from integer or Roman numeral string
function init( input ) {
variables.value = isNumeric( input ) ? int( input ) : static.fromRoman( input );
return this;
}
// Parse a Roman numeral string into its integer value
static function fromRoman( s ) {
s = uCase( s );
return static.VALUES.reduce( ( acc, val, idx ) => {
var sym = static.SYMBOLS[ idx ];
while( mid( s, acc.pos, sym.len() ) == sym ) {
acc.result += val;
acc.pos += sym.len();
}
return acc;
}, { result: 0, pos: 1 } ).result;
}
// Convert an integer to its Roman numeral string representation
static function toRoman( num ) {
return static.VALUES.reduce( ( result, val, idx ) => {
var count = int( num / val );
num -= count * val;
return result & repeatString( static.SYMBOLS[ idx ], count );
}, "" );
}
// Display as Roman numeral string
function toString() { return static.toRoman( variables.value ); }
// Return a new Roman advanced by step positions
function rangeAdvance( step ) { return new Roman( variables.value + step ); }
// Compare by underlying integer value
function rangeCompare( other ) { return variables.value - other.getValue(); }
// Convert integers or Roman numeral strings to Roman instances
function rangeCoerce( val ) {
if( val instanceof "Roman" ) return val;
if( isNumeric( val ) ) return new Roman( val );
if( isSimpleValue( val ) ) return new Roman( val );
return null;
}
}
Iterating Roman Numerals
result = [];
for( r in new Roman("I")..new Roman("X") ) {
result.append( r.toString() );
}
// ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]
Stepping by 2
result = [];
for( r in (new Roman("I")..new Roman("X")).step(2) ) {
result.append( r.toString() );
}
// ["I", "III", "V", "VII", "IX"]
Contains with Multiple Input Types
The rangeCoerce() method enables contains() to accept Roman instances, integers, or string Roman numerals:
range = new Roman("I")..new Roman("C"); // 1 to 100
// Roman instances
range.contains( new Roman("V") ) // true
range.contains( new Roman("L") ) // true
range.contains( new Roman("D") ) // false (500 > 100)
// Raw integers
range.contains( 5 ) // true
range.contains( 50 ) // true
range.contains( 500 ) // false
// String Roman numerals
range.contains( "V" ) // true
range.contains( "L" ) // true
range.contains( "D" ) // false
Example 3: Musical Notes with Unit Stepping
This is where it gets interesting. A Musical Note class that supports chromatic, whole-step, major-third, octave, major scale, and minor scale stepping — plus stream composition for infinite sequences.
class Note implements="java:ortus.boxlang.runtime.types.IRangeable" {
property name="midi" type="integer" default=60;
static {
NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
MAJOR = [2, 2, 1, 2, 2, 2, 1];
MINOR = [2, 1, 2, 2, 1, 2, 2];
}
// Construct from MIDI number or note name string (e.g. "C4")
function init( input ) {
variables.midi = isNumeric( input ) ? int( input ) : static.parseName( input );
return this;
}
// Parse a note name like "C#4" into its MIDI number
static function parseName( name ) {
name = uCase( name );
var hasSharp = name.len() > 2 && mid( name, 2, 1 ) == "#";
var notePart = hasSharp ? left( name, 2 ) : left( name, 1 );
var octave = int( right( name, name.len() - notePart.len() ) );
var idx = arrayFind( static.NOTES, notePart );
return ( octave + 1 ) * 12 + idx - 1;
}
// Display as note name with octave (e.g. "C4")
function toString() {
return static.NOTES[ variables.midi mod 12 + 1 ] & ( int( variables.midi / 12 ) - 1 );
}
// Return a new Note advanced by step semitones
function rangeAdvance( step ) { return new Note( variables.midi + step ); }
// Compare by MIDI number
function rangeCompare( other ) { return variables.midi - other.getMidi(); }
// Convert MIDI numbers or note name strings to Note instances
function rangeCoerce( val ) {
if( val instanceof "Note" ) return val;
if( isNumeric( val ) ) return new Note( val );
if( isSimpleValue( val ) ) return new Note( val );
return null;
}
// Convert named units to a fixed semitone count
function rangeStepFromUnit( amount, unit ) {
switch( unit ) {
case "chromatic": return amount;
case "whole": return amount * 2;
case "third": return amount * 4;
case "octave": return amount * 12;
}
throw( message: "Unsupported unit: " & unit );
}
// Return a closure for non-uniform scale stepping (major/minor)
function rangeUnitStepper( unit ) {
if( unit != "major" && unit != "minor" ) return null;
var root = variables.midi;
var intervals = ( unit == "major" ) ? static.MAJOR : static.MINOR;
var cumulative = [0];
for( var i = 1; i <= 7; i++ ) {
cumulative.append( cumulative[ i ] + intervals[ i ] );
}
return ( current, amount ) => {
var offset = current.getMidi() - root;
var octaves = int( offset / 12 );
var remainder = offset mod 12;
var degree = 0;
for( var i = 2; i <= cumulative.len(); i++ ) {
if( cumulative[ i ] <= remainder ) degree = i - 1;
}
var newDegree = octaves * 7 + degree + amount;
var newOctaves = int( newDegree / 7 );
var newDegreeInOctave = newDegree mod 7;
return new Note( root + ( newOctaves * 12 ) + cumulative[ newDegreeInOctave + 1 ] );
};
}
}
Understanding the Two Stepper Mechanisms
rangeStepFromUnit() handles uniform steps — where each step is the same fixed number of semitones:
"chromatic"→ 1 semitone"whole"→ 2 semitones"third"→ 4 semitones (major third)"octave"→ 12 semitones
rangeUnitStepper() handles non-uniform steps — where the step size varies depending on position. Major and minor scales have irregular intervals (whole, whole, half, whole, whole, whole, half for major), so this method returns a closure that computes the correct next note based on scale degree.
Chromatic Range
result = [];
for( n in (new Note("C4")..new Note("D4")).step( 1, "chromatic" ) ) {
result.append( n.toString() );
}
// ["C4", "C#4", "D4"]
Whole Steps
result = [];
for( n in (new Note("C4")..new Note("C5")).step( 1, "whole" ) ) {
result.append( n.toString() );
}
// ["C4", "D4", "E4", "F#4", "G#4", "A#4", "C5"]
Major Thirds
result = [];
for( n in (new Note("C4")..new Note("C6")).step( 1, "third" ) ) {
result.append( n.toString() );
}
// ["C4", "E4", "G#4", "C5", "E5", "G#5", "C6"]
Octaves
result = [];
for( n in (new Note("C4")..new Note("C7")).step( 1, "octave" ) ) {
result.append( n.toString() );
}
// ["C4", "C5", "C6", "C7"]
C Major Scale (Non-Uniform Stepping)
The major scale has irregular intervals — this is where rangeUnitStepper() shines:
result = [];
for( n in (new Note("C4")..new Note("C5")).step( 1, "major" ) ) {
result.append( n.toString() );
}
// ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5"]
A Natural Minor Scale
result = [];
for( n in (new Note("A3")..new Note("A4")).step( 1, "minor" ) ) {
result.append( n.toString() );
}
// ["A3", "B3", "C4", "D4", "E4", "F4", "G4", "A4"]
Half-Bounded Range + Stream: Full Chromatic Scale
Use a half-bounded range with stream and limit() to collect exactly one octave from any starting note:
chromaticScale = (new Note("C4")..).step( 1, "chromatic" )
.stream()
.limit( 13 )
.map( n => n.toString() )
.toList();
// ["C4", "C#4", "D4", "D#4", "E4", "F4", "F#4", "G4", "G#4", "A4", "A#4", "B4", "C5"]
No end note needed — just “start here, give me 13 chromatic notes.”
Stepped Contains: Reachability
On a stepped range, contains() checks whether the value is actually produced by the stepper — not just within bounds. This works on both bounded and half-bounded ranges:
// Major thirds from C4 to C5 produce: C4, E4, G#4, C5
thirdRange = (new Note("C4")..new Note("C5")).step( 1, "third" );
// On the step sequence — reachable
thirdRange.contains( "C4" ) // true
thirdRange.contains( "E4" ) // true
thirdRange.contains( "G#4" ) // true
thirdRange.contains( "C5" ) // true
// Within bounds but NOT reachable by thirds
thirdRange.contains( "D4" ) // false
thirdRange.contains( "F4" ) // false
thirdRange.contains( 62 ) // false (D4 as MIDI — coercion still works)
// Half-bounded: Is C6 reachable by octaves from C4?
(new Note("C4")..).step( 1, "octave" ).contains( "C6" ) // true
(new Note("C4")..).step( 1, "octave" ).contains( "G7" ) // false (octaves only hit C notes)
// Half-bounded: Is F#5 in C major?
(new Note("C4")..).step( 1, "major" ).contains( "F#5" ) // false
(new Note("C4")..).step( 1, "major" ).contains( "F5" ) // true
Implementing Your Own IRangeable: Checklist
-
rangeAdvance( step )— Return a new instance moved bysteppositions. This is your “next value” generator. -
rangeCompare( other )— Return negative/zero/positive like Java’scompareTo(). This defines ordering for contains checks and iteration bounds. -
rangeCoerce( val )— Convert foreign values to your type. Returnnullif the value can’t be converted. This enablescontains()to accept multiple input types. -
rangeStepFromUnit( amount, unit )(optional) — Convert named units to numeric step values. Only needed if your type supports unit-based stepping with uniform intervals. -
rangeUnitStepper( unit )(optional) — Return a closure(current, amount) => nextValuefor non-uniform stepping patterns. Returnnullto fall through torangeStepFromUnit().
Summary
The IRangeable interface lets any domain object participate in BoxLang’s range ecosystem with full support for:
- Iteration with
for..in - Custom uniform and non-uniform stepping
contains()with automatic type coercion- Step-reachability semantics — including non-linear sequences like Fibonacci
- Java Stream API composition (
map,filter,limit,takeWhile,anyMatch) - Half-bounded infinite sequences with lazy evaluation
- Exclusive boundaries
All from a simple five-method interface (three required, two optional) on a plain BoxLang class.