ISO 8601 Durations in Go

This post is part of a series about chrono - an alternative Go module for working with dates and times.

Problem

If you’ve worked with the standard library’s time package before, you’ll know that the Duration type provides a way to describe a period of elapsed time, with nanosecond precision. Since Duration is just an int64, we can create a value and use the String function to format it to a string:

d := 3*time.Hour + 7*time.Minute + 500*time.Millisecond
fmt.Println(d)
// 3h7m0.5s

Play: https://go.dev/play/p/3f4wFEoiknT

This is very convenient, and the formatted string does a good job of being concise, yet still easily readable.

Unfortunately though, this string is not very useful for interfacing with other systems that don’t share Go’s idea of how durations should be formatted. Many systems, when communicating periods of time, use the ISO 8601 standard. Such a system would be expecting a string like PT3H7M0.5S. Perhaps not quite as readable, but interoperability is of most importance here, and Go’s standard library has no support for producing or consuming such strings.

Solution - chrono

Using the chrono.Duration type, the task above can be accomplished with very similar code:

d := chrono.DurationOf(3*chrono.Hour + 7*chrono.Minute + 500*chrono.Millisecond)
fmt.Println(d)
// PT3H7M0.5S

Play: https://go.dev/play/p/SCllVS3rd4k

And we can also parse such strings:

var d chrono.Duration
_ = d.Parse("PT3H7M0.5S")

Going a bit further…

So chrono can be used to solve the problem above, but we can also do more.

ISO 8601 describes durations in units other than just hours, minutes, and seconds. More specifically, a duration looks like this: PnYnMnDTnHnMnS. We can therefore represent a time interval by reference to years, months, days, hours, minutes, and seconds. P in this case stands for period, and T for time. A duration must always begin with P, even if none of those units are used, and there must be at least one unit included. Hence, in the example above, where we’re just talking about hours, minutes, and seconds, PT2H is how we’d represent 2 hours.

So this is a very useful way of communicating time intervals. But we must also be careful not to make incorrect assumptions. ISO 8601 defines an hour as 60 minutes, and a minute as 60 seconds (or 61 in a leap minute), as you might expect. But, years, months, and days do not represent fixed lengths of time:

  • The length of a day can vary with daylight savings, so P1D is not the same as PT24H;
  • The length of a month varies with the number of days it contains; and,
  • The length of a year depends on whether it is a leap year.

What this means is that you can compare two durations that only contain the T part for equivalence with one another (PT1M is the same as PT60S), but if the P part is included, you can only say whether they’re exactly the same or not (we can’t say anything for sure about P1Y and P366D other than the fact that they’re not equal).

chrono handles this situation by having two separate types: Duration (as demonstrated above), and Period (which contains years, months, weeks, and days). But they can be combined to parse and format ISO 8601 strings that contain both parts. Here’s how we can format such a string:

p := chrono.Period{Years: 3, Months: 6, Days: 4}
d := chrono.DurationOf(1*chrono.Hour + 30*chrono.Minute + 5*chrono.Second)
fmt.Println(chrono.FormatDuration(p, d))
// P3Y6M4DT1H30M5S

Play: https://go.dev/play/p/TwgFcWJ9Dyx

And here’s how we can reverse that and parse the same string:

p, d, _ := chrono.ParseDuration("P3Y6M4DT1M5S")

chrono contains many more features besides, and the aim is to cover the majority of ISO 8601, filling in gaps that the standard library leaves. Future posts will cover these features in detail.