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: Date
, Time
, and DateTime
. Each has advantages and limitations, but one significant difference is the units in which they operate.
Date
Units of days with no concept of hours, minutes or seconds
Time
Units of seconds, counting since the start of the Unix Epoch
DateTime
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 1.day
or 3.days.ago
or 7.days.from_now
, or even concepts like beginning_of_quarter
.
What caught me out was the difference between date + 3.days
and 3.days.from_now
.
3.days
returns an ActiveSupport::Duration
which is compatible with Date
, Time
& DateTime
.
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 Time
).
Date.today - 1.minute => ActiveSupport::TimeWithZone
3.days.from_now
returns an ActiveSupport::TimeWithZone
which behaves like Time
, rather than Date
.
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 Date
and 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 DateTime
.
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 DateTime
, Date
and 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
TL;DR
In modern Ruby, Time
and DateTime
are largely indistinguishable. So much so that Ruby offers a helpful rubric for choosing which class to use. In contrast Date
and Time
are significantly different, and ActiveSupport
adds another layer of complexity.
References and Further Reading
https://www.rubyguides.com/2015/12/ruby-time/
https://medium.com/@muesingb/whats-the-difference-between-time-and-datetime-in-ruby-fa3cc844c9d7
https://infiniteundo.com/post/25326999628/falsehoods-programmers-believe-about-time
https://blog.dnsimple.com/2018/03/elapsed-time-with-ruby-the-right-way/
Interesting! I had a similar problem in SQL!