Dating with Go

Throughout my experience with the Go programming language I've often had the doubtful pleasure of writing and maintaining code related to handling times and dates. I would like to highlight two problems that I found to be very common in many codebases.

Code dealing with daily or weekly processes

I've quite often encountered patterns similar to the following applied when the author wanted to find a date in the future due to some weekly aggregation that had to be performed:

monday := time.Date(2020, month.March, 9, 0, 0, 0, 0, time.UTC)
nextMonday := monday.Add(7 * 24 * time.Hour)

This code is obviously incorrect. The mistake stems from a very long list of time-related misconceptions which appear to be true at first glance but are actually completely wrong:

  • a minute has 60 seconds
  • an hour has 60 minutes
  • a day has 24 hours
  • a week has 7 days

Relying on adding time.Duration to time.Time in order to jump to the next month/week/hour/minute is therefore incorrect. In order to perform a similar calculation you could instead do the following:

monday := time.Date(2020, month.March, 9, 0, 0, 0, 0, time.UTC)
nextMonday := monday.AddDate(0, 0, 7)

The important thing to realize is what the actual purpose of your action is. Do you actually want to add 7 days/168 hours/10080 minutes/604800 seconds to the current time? That is quite doubtful; the intent behind a piece of code dealing with processes occuring weekly or daily is usually changing a date in the calendar which you should consider to be completely unrelated to time intervals. This means simply adding a number to the number of the day and normalizing the result, not adding a time interval to a time instant. The code utilising Time.AddDate is not only correct but also understandable and clear; this way, there is no misunderstanding the intent behind it.

It is important to notice that if one wants to jump to the next Monday and not just 7 days forward this method is also incorrect. Instead it would be necessary to check every following day until you find a Monday.

The helper method Time.AddDate cannot be used for changing the time on the clock - in this case, your own function would have to be written, as moving the clock forward is therefore also different than just adding a duration to time.Time:

func AddTime(t Time, years int, months int, days int, hours int, mins int, secs int) Time {
    year, month, day := t.Date()
    hour, min, sec := t.Clock()
    return Date(year+years, month+Month(months), day+days, hour+hours, min+mins, sec+secs, int(t.nsec()), t.Location())
}

Attempting to use time.Time for dates

From my experience it is common to misuse time.Time in your domain logic related to processes occuring on daily, weekly or monthly basis, for example many financial processes. Periods in those processes usually start or end on a particular day, not a time instant. The difference is subtle but significant.

As an example, I've encountered encoding the beginning of a week as hour 00:00 on Monday. The problems arise when trying to represent the end of the week. Do we use 00:00 on Sunday? Do we try to select the last second of the day on Sunday (this is a terrible idea)? Should we use 00:00 on the next Monday instead? But then why do the two adjacent time periods appear to overlap? For these reasons, selecting events from a time period forces you to remember that the interval is equal to [a, b) and not [a, b].

All similar problems are immediately resolved by introducing an appropriate domain type. You should not be scared of defining of your own time-related types - what you should avoid is performing time-related calculations on your own.

type Date struct {
    year     int
    month    time.Month
    day      int
    location time.Location
}

func NewDate(year int, month time.Month, day int, location time.Location) (Date, error) {
    d := time.Date(year, month, day, 0, 0, 0, 0, location)
    if y, m, d := d.Date(); y != year || m != month || d != day {
        return Date{}, fmt.Errorf("passed date is invalid, year: %d, month: %s, day: %d", year, month, day)
    }
    return Date{year, month, day, location}, nil
}

A type like this can be extremely useful and can be easily extended with a myriad of useful methods:

func (d Date) Includes(t time.Time) bool {
    year, month, day := t.In(d.location).Date()
    return year == d.year && month == d.month && day == d.day
}

Such domain-specific types are extremely useful and can help you avoid easy-to-miss bugs and unnecessary complexity in the logic. If what you are representing is a date then why would you represent it as a time in your code? That is effectively telling a lie about your domain in your domain logic for the sake of some misunderstood convenience.

Many people say that the standard library only contains time.Time because it is the only type which you need do to perform time-related calculations. This is absolutely correct, however a domain-specific type is usually more suitable to encapsulating potentially dangerous and complex time-related logic. And, as the name suggests, it may not be a good idea to handle dates using a type representing a time instant called time.Time.

I believe that programming is too complicated and error-prone even without data types misrepresenting the reality but clinging to a single data type throughout the program still seems to be one of the common problems encountered in many codebases.

2020-03-10