BoxLang Ranges Part 2: Custom Types with IRangeable

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 by step positions
  • rangeCompare( other ) — Compare to another instance (negative = less, 0 = equal, positive = greater)
  • rangeCoerce( val ) — Convert arbitrary values to your type for contains() checks
  • rangeStepFromUnit( amount, unit )(Optional) Convert a unit-based step to a numeric step
  • rangeUnitStepper( 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

  1. rangeAdvance( step ) — Return a new instance moved by step positions. This is your “next value” generator.

  2. rangeCompare( other ) — Return negative/zero/positive like Java’s compareTo(). This defines ordering for contains checks and iteration bounds.

  3. rangeCoerce( val ) — Convert foreign values to your type. Return null if the value can’t be converted. This enables contains() to accept multiple input types.

  4. rangeStepFromUnit( amount, unit ) (optional) — Convert named units to numeric step values. Only needed if your type supports unit-based stepping with uniform intervals.

  5. rangeUnitStepper( unit ) (optional) — Return a closure (current, amount) => nextValue for non-uniform stepping patterns. Return null to fall through to rangeStepFromUnit().


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.