Bossy Lobster

A blog by Danny Hermes; musing on tech, mathematics, etc.

Edit on GitHub

The missing type in the Go standard library: Date!

go-date

This is cross-posted from the Hardfin engineering blog.

The Go standard library uses a single overloaded type as a stand-in for both full datetimes1 and dates. This mostly "just works", but slowly starts to degrade correctness in codebases where both datetime and date values need to interact.

We store events using a mix of datetime and date columns in our database (depending on the type of event). Having a mix of datetimes and date values became a persistent source of subtle confusion and extended discussion2 within our engineering team. As a result, we created a simple Date type that organically grew until it could replace all of our existing usage of time.Time.

The go-date package is here to fill the gap left by the Go standard library!

Having a mix of datetimes and date values became a persistent source of subtle confusion and extended discussion within our engineering team

Opportunity for correctness to degrade

To understand an example where correctness can start to degrade, consider a datetime value (now) and a date value (deadline):

now := time.Now()
// Common convention for date values is to use a `time.Time` with
// only the year, month, and day set. For example, this convention
// is followed by the standard library when a timestamp of the form
// YYYY-MM-DD is parsed via `time.Parse(time.DateOnly, value)`.
deadline := time.Date(2024, time.March, 1, 0, 0, 0, 0, time.UTC)

With two time.Time values, it's very reasonable for a developer to compute the check "current date is on or before the deadline" via:

withinDeadline := !now.After(deadline)

However, this is problematic for at least two reasons. First and foremost, the timestamp of deadline is 00:00, so it leaves out virtually 100% of the deadline day. Secondly, the concept of "today" depends3 on the timezone and for many applications, the application user likely has a specific timezone that is natural for that check.

In codebases maintained over time by teams of different people, it's very common for a developer to come in and edit existing code that they didn't write. In cases like this, it's easy to have less context than the original author.

Without sufficient context, mixing up the precision of now (datetime) and the precision of deadline (date) is an easy mistake to make. On the other hand, if deadline was not the same type as now, developers are forced to consider the difference between the two types and account for this difference. If a Date was used to represent the deadline instead:

deadlineDate := date.NewDate(2024, time.March, 1)

then a correct implementation can both incorporate the timezone and ensure that same percentage of the hours in the day are not erroneously ignored:

nowDate := date.InTimezone(now, tz)
withinDeadline := !nowDate.After(deadlineDate)

Missing pieces

Integrating with sqlc

We use the sqlc library at Hardfin for all Go code that interacts with the database. (See the wonderful post by PostgreSQL blogging legend Brandur Leach for some of the benefits of sqlc.)

Out of the box, sqlc uses a Go time.Time both for columns of type TIMESTAMPTZ and DATE. When reading DATE values (which come over the wire in the form YYYY-MM-DD), the Go standard library produces values of the form:

time.Date(YYYY, MM, DD, 0, 0, 0, 0, time.UTC)

Instead, we can instruct sqlc to globally use date.Date and date.NullDate when parsing DATE columns:

---
version: "2"
overrides:
  go:
    overrides:
      - go_type:
          import: github.com/hardfinhq/go-date
          package: date
          type: NullDate
        db_type: date
        nullable: true
      - go_type:
          import: github.com/hardfinhq/go-date
          package: date
          type: Date
        db_type: date
        nullable: false

Why do we care?

At Hardfin, we help equipment manufacturers automate their financial operations by connecting real world events to hardware subscriptions. This inherently means we collect and store a lot of dates.

For many event types, we are able to collect both the date and the timestamp. For example, if a user action occurs in our application or if an update comes in from a third party system like Stripe. However for some events, the timestamp is not required, not important, and oftentimes not known. For example, an operator may know that "a robot was shipped on December 28" but may not know the exact time of day that robot was shipped. The date December 28 has enough information without the timestamp to trigger automation for the manufacturer's billing and revenue.

Connecting events

Go forth and differentiate

This package is intended to be simple! (Simple to understand and simple to implement.) The package is only intended to cover "modern" dates (i.e. dates between the years 1900 and 2100) and so it can avoid resorting to the proleptic Gregorian calendar.

As a result, the core Date type directly exposes the year, month, and day as struct fields.

We hope this package will come in handy for teams that need to draw a clear distinction between datetime and date values!

Pick a date

  1. Here by datetime, we mean an object that contains both date (year, month, day), time information (hour, minute, second, millisecond / microsecond / nanosecond), and often a relevant timezone as well. For example this is provided by the datetime.datetime type in Python and Date in JavaScript.
  2. Those discussions usually included a phrase like "I wish the Go standard library had a separate Date type"!
  3. Why does the "today" depend on the timezone? If a friend from Los Angeles calls at 11pm, but you're in New York, today is tomorrow! (Or yesterday, depending on who says it.)

Comments