As you would expect from small business accounting software like FreeAgent, we deal with a lot of dates and times. Ruby on Rails has some really useful helper methods — but there are also a few unexpected quirks in the way that different date and time classes interact. One of those quirks sadly caught me out recently.
Picture the scene
I’ve written tests, the CI pipeline has passed and my pull request has been accepted. The only thing standing between my code and production is some pre-production testing (checks done by another engineer to make sure everything looks good before the code goes live). I’m feeling good!
Then I get a Slack message: Hey Lorna, why am I getting “TypeError: can’t convert Date into an exact number” when I run your code?
Say what? Ruby isn’t supposed to throw type errors! All I’m doing is subtracting one date from another…
To understand what happened, I’ll need to take you on a brief tour of Ruby and Rails and the different ways in which they treat the concept of duration. The problem isn’t unique to Ruby, or Rails, of course — you’ve probably bumped into something similar if you’ve ever tried to work with dates and times in any programming language. And let’s not even mention timezones…
Dates in Ruby
If you’re working with dates in Ruby you have several classes to choose from:
DateTime. Each has advantages and limitations, but one significant difference is the units in which they operate.
Units of days with no concept of hours, minutes or seconds
Units of seconds, counting since the start of the Unix Epoch
Units of days, but does include the concept of hours, minutes and seconds
Calendar-based, and understands the concept of calendar reform
So far, so good. I had chosen to use
Date and was subtracting one date from another in order to calculate a number of months:
def self.rounded_up_number_of_months_between(first_date, second_date) ((second_date - first_date).to_f / 365 * 12).ceil end
Dates in Rails
Rails ActiveSupport includes a number of helper methods to make working with duration more intuitive. You can use phrases like
7.days.from_now, or even concepts like
What caught me out was the difference between
date + 3.days and
3.days returns an
ActiveSupport::Duration which is compatible with
And that’s where it gets interesting.
Adding or subtracting an
ActiveSupport::Duration will return the same type of object.
Date.today - 1.day => Date
DateTime.now + 1.minute => DateTime
Time.now - 1.minute => Time
The exception is where you are modifying a
Date by a number of hours, minutes or seconds when it will be coerced into
ActiveSupport::TimeWithZone (which implements the same interface as Ruby
Date.today - 1.minute => ActiveSupport::TimeWithZone
3.days.from_now returns an
ActiveSupport::TimeWithZone which behaves like
Time, rather than
This is where my error was coming from.
My colleague had passed in
90.days.from_now instead of
Date.today + 90.days in order to set the end date for the calculation, and
Date.today to set the start date.
(end_date - start_date) was thus
(ActiveSupport::TimeWithZone - Date)
The reason for the error is that
Time use different units: days for the
Date and seconds for the
Time. As a result, Ruby doesn’t know how to interpret what you’ve passed in, and throws a
TypeError. In the same way, you can’t subtract a
Time from a
Date - Time => TypeError: expected numeric
DateTime - Time => TypeError: expected numeric
Time - Date => TypeError: can't convert Date into an exact number
This isn’t the only problem with mixing classes either. Because of the differing units between
Time, you might end up with a perfectly valid calculation but an answer which makes no sense in the context of your code.
Date - DateTime => Rational number of days
Time - DateTime => Float number of seconds
In modern Ruby,
DateTime are largely indistinguishable. So much so that Ruby offers a helpful rubric for choosing which class to use. In contrast
Time are significantly different, and
ActiveSupport adds another layer of complexity.
References and Further Reading
One thought on “The perils of a bad date”
Interesting! I had a similar problem in SQL!