Custom Ranges in Ruby

Joël Quenneville

Ruby allows you to create ranges for custom objects by implementing just a couple standard methods: <=> and succ.

Problem

You need to generate an array of months between two arbitrary dates. Normally this sort of thing can be solved with a range but date ranges give you an entry for every day, not every month.

You could generate all the days and then filter them such that you only keep one per month but that forces you to create a lot more data than you actually need. It also leaves you with an array of Date objects which don’t really represent the concept you are trying to work with.

(date1..date2).select { |date| date.day == 1 }

Domain Object

Instead of working with Date objects that represent the concept of a day in time, we might want to implement our own value object that represents the concept of a month in time.

class Month
  def initialize(months)
    # months since Jan, 1BCE (there is no year zero)
    # https://en.wikipedia.org/wiki/Year_zero
    @months = months
  end
end

The standard way to model time in software is to store a single counter since a set point in time (e.g. 24,267 months since 1BCE) rather than multiple counter like in English (e.g. 2022 years and 4 months since 1BCE). It makes implementing both math and domain operations much easier.

To make this object a bit nicer to work with, we might add some convenience methods like:

  • A .from_parts class method as an alternate constructor
  • #month and #day accessors to get human values
  • a custom #inspect to make it easier to read output from the console and tests

You can see a full implementation in this gist.

Becoming rangeable

Ruby can construct a range out of any object that implements the <=> comparison operator. If you want that range to be iterable, you also need to implement succ to generate the next value.

class Month
  # other methods...

  def <=>(other)
    months <=> other.months
  end

  def succ
    self.class.new(months.succ)
  end
end

Note that since we are using a single value internally, we get to just delegate the methods to the internal number.

Generating our array

Let’s see it in action!

Month.from_parts(2021, 10)..Month.from_parts(2022, 3)
=> [October 2021, November 2021, December 2021, January 2022, February 2022, March 2022]

By implementing just a couple methods, we are now able to generate a series of months. Yay interfaces and polymorphism! As a bonus, we also get a nice value object that will likely have more relevant methods for our domain than a regular Date would.