Handrolling ISO8601 Duration Support for Go

Table of Contents

Tags:

Info - TLDR

I wrote my own ISO8601 duration parsing library because the common ones sucked. ISO8601 durations are defined as P[nn]Y[nn]M[nn]DT[nn]H[nn]M[nn]S or P[nn]W.

See:

I’m currently working on a project that requires interacting with the api of a german public transport provider, specifically vbb. To abstract this I wrote go-hafas, however, all fields that include duration are serialized to something along the lines of PT3M, for instance:

JSONC
1{
2  // ...
3
4  // means something like between two stops you require at least this amount
5  // to successfully walk from the previous arrival to the next departure
6  "minimumChangeDuration": "PT2M",
7  // ...
8}

To use these durations for something like UI, sorting, or internal logic, I need to parse them into something Go native.

No Support In Go’s Stdlib

I thought, sure, I’ll just use time.Duration and parse the format via time.ParseDuration, until I found out:

  1. There is no ISO8601 support in either time.Parse (there is support for an ISO8601 subset: time.RFC3339, just not for a duration) or time.DurationParse, which only accepts “custom” duration strings someone at google came up with I guess

    GO
    1// ParseDuration parses a duration string.
    2// A duration string is a possibly signed sequence of
    3// decimal numbers, each with optional fraction and a unit suffix,
    4// such as "300ms", "-1.5h" or "2h45m".
    5// Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
    6func ParseDuration(s string) (Duration, error) {
  2. time.Duration is only backed internally by a int64, this doesn’t mean it can’t contain the ISO8601 duration spec, just fun to know

    GO
    1// A Duration represents the elapsed time between two instants
    2// as an int64 nanosecond count. The representation limits the
    3// largest representable duration to approximately 290 years.
    4type Duration int64

No “well done” library

So I went looking for a library, which left me dumbfounded, because none of them supported my obvious use case (converting a ISO8601 duration to time.Duration) and some weren’t even spec compliant (see next chapter):

LibraryWhy I didnt chose it
dacut/go-iso8601No Duration support
relvacode/iso8601No Duration support
sosodev/durationAccepts faulty inputs, doesn’t treat W as exclusive
senseyeio/durationUses regex (preference), Doesn’t treat W as exclusive

So I, as every sensible software developer would, started to write my own library: go-iso8601-duration.

Finding the Specification

This is the part where I was severly pissed off. First of all, I understand why the go stdlib only supports a ISO8601 subset, because: RFC3339 is freely accessible and ISO8601 is a cluster fuck of backwards compatibility and absurd formats. Cutting the fat down to a subset of format strings was a good decision. I can only recommend RFC 3339 vs ISO 8601 vs HTML, the visualisation shows the whole scale of cursedness ISO8601 and HTML bring to the world. Also, technically RFC3339 defines ISO8601 durations while not going into further detail on them anywhere, see RFC3339 - Appendix A. ISO 8601 Collected ABNF.

Germany (the country I am from and pay taxes to), contributes 7.6% of the OECD budget per anno, applying this to membership fees of ISO’s financial performance in 2023:

Operating revenue2023
Membership fees21 359
Royalties received from members selling ISO standards14 949
Revenue from members36 308
Revenue – net sales6 620
Funded activities
Funds to support Capacity Building activities2 620
Funds to support ISO Strategy1 800
Funded activities - revenue4 420
Total revenue47 348

We get a staggering \(21359 \cdot 0.076 = 1623.284\)kCHF, which is around \(1,737,227.20\textrm{€}\) of taxpayers money I contribute to. As a german I love standards and the idea behind ISO and DIN (Germanys ISO member body). What I don’t love is my taxes going to a organisation that produces standards that aren’t freely accessible for anyone, not for library implementers, not for oss contributors and not even for the tax payers financing said organisation. To add insult to inury, they fucking charge 227€ for the current ISO8601 version: ISO 8601-2:2019-02. Even the one before that costs 80 bucks. Anyway, I used the very good wikipedia ISO8601 - Durations and found some public versions of the full spec (ISO8601 - 4.4.3 Duration), out of all places, from the us library of congress .

Understanding the Specification

4.4.1 Separators and designators requires: “[…] P shall precede […] the remainder of the […] duration”.

4.4.3.1 General defines a Duration as “[…] a combination of components with accurate duration (hour, minute and second) and components with nominal duration (year, month, week and day).”. A component is made up of a variable amount of numeric characters and a designator.

4.4.3.2 Format with designators defines this further: “The number of years shall be followed by […] Y”, “the number of months by M”, “the number of weeks by W”, “and the number of days by D”. “[…] [the] time components shall be preceded by […] T”. It also defines “[…] number of hours […] by H”, “[…] minutes by M” and “[…] seconds by S”.

Furthermore “[…] both basic and extended format […] shall be PnnW or PnnYnnMnnDTnnHnnMnnS”. The count of numeric characters (nn) before a designator is somehow agreed upon by the members of the information exchange, so even something absurd like 256 would be valid, who even thought that would be smart? There are also some exceptions to the before:

  1. […] components may be omitted […]
  2. if necessary for a particular application, the lowest order components may have a decimal fraction” (I skipped this one, because I don’t think this is a good feature, but I’m open to adding this in the future, if necessary)
  3. […][if no other component is present] at least one number and its designator shall be present
  4. […] T shall be absent if all time components are absent

Summary

  1. A duration format string must start with a P for period
  2. There are the following designators for the date component:
    • Y: year
    • M: month
    • D: day
  3. There are the following designators for the time component:
    • H: hour
    • M: minute
    • S: second
  4. Each designator needs a number and each number needs a designator
  5. Pairs can be omitted, if empty
  6. The time component must start with T, but can be omitted if no time designators are found
  7. Using W is exclusive to all other designators
  8. There always needs to be at least one designator and number pair

And some examples for the above:

ExamplePair wise
P1Y2M3DT1H2M3SP 1Y 2M 3D T 1H 2M 3S
P1Y2M3DP 1Y 2M 3D
PT1H2M3SP T 1H 2M 3S
P56WP 56W
P0DP 0D

Handrolling ISO8601 Durations

After having somewhat understood the spec, lets get started with going through my process of implementing the library.

Defining a Duration Container

GO
1type ISO8601Duration struct {
2	year, month, week, day, hour, minute, second float64
3}

The API

A simple interaction interface:

GO
 1// P[nn]Y[nn]M[nn]DT[nn]H[nn]M[nn]S or P[nn]W, as seen in
 2// https://en.wikipedia.org/wiki/ISO_8601#Durations
 3//
 4// - P is the duration designator (for period) placed at the start of the duration representation.
 5//   - Y is the year designator that follows the value for the number of calendar years.
 6//   - M is the month designator that follows the value for the number of calendar months.
 7//   - W is the week designator that follows the value for the number of weeks.
 8//   - D is the day designator that follows the value for the number of calendar days.
 9//
10// - T is the time designator that precedes the time components of the representation.
11//   - H is the hour designator that follows the value for the number of hours.
12//   - M is the minute designator that follows the value for the number of minutes.
13//   - S is the second designator that follows the value for the number of seconds.
14func From(s string) (ISO8601Duration, error) {}
15func (i ISO8601Duration) Apply(t time.Time) time.Time {}
16func (i ISO8601Duration) Duration() time.Duration {}

Using it as below:

GO
 1package main
 2
 3import (
 4	"fmt"
 5	"time"
 6
 7    "github.com/xnacly/go-iso8601-duration"
 8)
 9
10func main() {
11	rawDuration := "PT1H30M12S"
12	duration, err := goiso8601duration.From(rawDuration)
13	if err != nil {
14		panic(err)
15	}
16
17    // 1h30m12s PT1H30M12S
18	fmt.Println(duration.Duration().String(), duration.String())
19    // 01:00:00 02:30:12
20	fmt.Println(
21		time.
22			Unix(0, 0).
23			Format(time.TimeOnly),
24		duration.
25			Apply(time.Unix(0, 0)).
26			Format(time.TimeOnly),
27	)
28}

I’m using this in production to interact with hafas data and so far I’m pretty happy with it.

Writing a Finite State Machine

The basis of any state machine, finite or not, is to define states the machine should operate on. For a more theoretical deep dive, do read the wikipedia article on finite state machines, its really good.

In general, the set of states is often defined by the differing input components a format can have. Each state machine should have a start and an end state. This makes bookkeeping for setting up internal state in the fsm easier to reason about.

Statify ISO8601

In the ISO8601 duration case, we of course also have the starting character, P, which is required. The other atypical character, is the optional T which instructs the parser to switch to time mode.

After these initial states and the atypical states, we can go over to the pairs of number and designator the duration format uses. We have to differentiate between designators and numbers while in time and default mode, for this we use stateTNumber and stateTDesignator in contrast to stateNumber and stateDesignator:

GO
 1type state = uint8
 2
 3const (
 4	stateStart state = iota
 5	stateP
 6
 7	stateNumber
 8	stateDesignator
 9
10	stateT
11	stateTNumber
12	stateTDesignator
13
14	stateFin
15)

Since I like to have a mathematical definition for things like these, we can pull up our mathematical fsm model. Its basically a quintuple of:

  1. \(\Sigma\): Input alphabet:

    $$ \{ P, T, Y, M, W, D, H, S, 0..9 \} $$
  2. \(S\): finite non-empty set of states:

    $$ \{ start, P, Number, Designator, T, TNumber, TDesignator, fin \} $$
  3. \(s_0\): initial state, \(s_0 \in S\):

    $$ start $$
  4. \(\delta\): state transition function, which maps any state to its following state

    $$ \delta := S \times \Sigma \rightarrow S $$

    However, since we need to be able to reject inputs, see Dealing with unexpected state transitions, we can redefine \(\delta\) into a partial function as follows:

    $$ \begin{align} \delta(s, x) &:= S \times \Sigma \rightarrow S \\ &\Leftrightarrow \begin{cases} P & \quad x = P, s = start \\ T & \quad x = T, s \in \{P, Y, M, D, Designator\} \\ Number & \quad x \in \{0..9\}, s \in \{P, Y, M, D, Designator\} \\ Designator & \quad x \in \{Y,M,D,W\}, s \in \{Number\} \\ TNumber & \quad x \in \{0..9\}, s \in \{T,TDesignator\} \\ TDesignator & \quad x \in \{H,M,S\}, s \in \{TNumber\} \\ fin & \quad \text{otherwise} \end{cases} \end{align} $$
  5. \(F\): the final set of states:

    $$ \{ fin \} $$

State transitions

state diagram for format

Stepping through ISO8601

Lets take for instance P25W and step through the fsm for this example:

\(s\)\(x\)\(\delta(s,x) \Rightarrow s_0\)Input
startPPP25W
P2Number25W
Number5Number5W
NumberWDesignatorW
DesignatorEOFfin

The same applies for formats including time components, like PT2M3S

\(s\)\(x\)\(\delta(s,x) \Rightarrow s_0\)Input
startPPPT2M3S
PTTT2M3S
T2TNumber2M3S
TNumberMTDesignatorM3S
TDesignator3TNumber3S
TNumberSTDesignatorS
TDesignatorEOFfin

Dealing with unexpected state transitions

Since I want to provide myself very extensive error contexts and I was disappointed with the usual unexpected input shenanigans, I whipped up the following error constants and the ISO8601DurationError for wrapping it with the position the error occured in:

GO
 1package goiso8601duration
 2
 3import (
 4	"errors"
 5	"fmt"
 6)
 7
 8var (
 9	UnexpectedEof                  = errors.New("Unexpected EOF in duration format string")
10	UnexpectedReaderError          = errors.New("Failed to retrieve next byte of duration format string")
11	UnexpectedNonAsciiRune         = errors.New("Unexpected non ascii component in duration format string")
12	MissingDesignator              = errors.New("Missing unit designator")
13	UnknownDesignator              = errors.New("Unknown designator, expected YMWD or after a T, HMS")
14	DuplicateDesignator            = errors.New("Duplicate designator")
15	MissingNumber                  = errors.New("Missing number specifier before unit designator")
16	TooManyNumbersForDesignator    = errors.New("Only 2 numbers before any designator allowed")
17	MissingPDesignatorAtStart      = errors.New("Missing [P] designator at the start of the duration format string")
18	NoDesignatorsAfterWeeksAllowed = errors.New("After [W] designator, no other numbers and designators are allowed")
19)
20
21type ISO8601DurationError struct {
22	Inner  error
23	Column uint8
24}
25
26func wrapErr(inner error, col uint8) error {
27	return ISO8601DurationError{
28		Inner:  inner,
29		Column: col,
30	}
31}
32
33func (i ISO8601DurationError) String() string {
34	return fmt.Sprint("ISO8601DurationError: ", i.Inner, ", at col: ", i.Column)
35}
36
37func (i ISO8601DurationError) Error() string {
38	return i.String()
39}

These errors already hint at all the errors the fsm can encounter, lets go top to bottom and contextualize each:

  1. UnexpectedEof: not much to say here, the duration string ended unexpectedly
  2. UnexpectedReaderError: reading a new rune from the input failed
  3. UnexpectedNonAsciiRune: a read rune wasn’t ascii, which a ISO8601 duration solely consists of
  4. MissingDesignator: the fsm encountered a number but no matching designator, for instance something like P5 instead of P5D
  5. UnknownDesignator: the fsm found a designator it doesn’t understand, only YMWD are valid in general and only HMS after T, P12A would be invalid, while PT5D, in place of P5D would be too
  6. DuplicateDesignator: the fsm already set that designator, ISO8601 doesn’t allow for duplicate duration unit designators, like P2D10D, which should be P12D
  7. MissingNumber: the fsm encountered a designator before its number, for instance: PD instead of P5D
  8. TooManyNumbersForDesignator: ISO8601 defines the amount of digit characters constructing the number before a designator to be decided by the producing and consuming party. Currently I used the examplary value of 2, which the spec uses and I found hafas to be using. Setting a unit number with more than two digit characters produces this error, for instance in PT120S.
  9. MissingPDesignatorAtStart: ISO8601 requires all duration strings to start with P (short for Period), thus making all strings not conforming to this invalid inputs, this error reflects that. 12D and T5H are invalid.
  10. NoDesignatorsAfterWeeksAllowed: A duration including weeks (W) is exclusive to all other designators and designator values, making P2W5D and P5D1W invalid inputs.

For instance, in the part of the state machine that handles getting a new character for advancing the state has several possible causes for failure, as lined out before:

GO
 1func From(s string) (ISO8601Duration, error) {
 2	var duration ISO8601Duration
 3
 4	if len(s) == 0 {
 5		return duration, wrapErr(UnexpectedEof, 0)
 6	}
 7
 8	curState := stateStart
 9	var col uint8
10
11	r := strings.NewReader(s)
12
13	for {
14		b, size, err := r.ReadRune()
15		if err != nil {
16			if err != io.EOF {
17				return duration, wrapErr(UnexpectedReaderError, col)
18			} else if curState == stateP {
19				// being in stateP at the end (io.EOF) means we havent
20				// encountered anything after the P, so there were no numbers
21				// or states
22				return duration, wrapErr(UnexpectedEof, col)
23			} else if curState == stateNumber || curState == stateTNumber {
24				// if we are in the state of Number or TNumber we had a number
25				// but no designator at the end
26				return duration, wrapErr(MissingDesignator, col)
27			} else {
28				curState = stateFin
29			}
30		}
31		if size > 1 {
32			return duration, wrapErr(UnexpectedNonAsciiRune, col)
33		}
34		col++
35
36        // ...
37	}
38}

Another case for failure is of course the start->P transition, which is mandatory for any valid input:

GO
 1func From(s string) (ISO8601Duration, error) {
 2    // ...
 3	for {
 4        // ...
 5		switch curState {
 6		case stateStart:
 7			if b != 'P' {
 8				return duration, wrapErr(MissingPDesignatorAtStart, col)
 9			}
10			curState = stateP
11        // ...
12	}
13}

Parsing numbers

As I explained above, I went for a maximum of two digit characters to make up the designator value:

GO
1// This parser uses the examplary notion of allowing two numbers before any
2// designator, see: ISO8601 4.4.3.2 Format with designators
3const maxNumCount = 2

This is reflected in the temporary buffer the fsm uses to store the digit characters as it walks the input:

GO
1var curNumCount uint8
2var numBuf [maxNumCount]rune

When encountering an integer it puts this integer into the buffer:

GO
 1case stateTNumber:
 2    if unicode.IsDigit(b) {
 3        if curNumCount+1 > maxNumCount {
 4            return duration, wrapErr(TooManyNumbersForDesignator, col)
 5        }
 6        numBuf[curNumCount] = b
 7        curNumCount++
 8        curState = stateTNumber
 9    }
10    // ...

If it hits a designator, either in the context of time (T) or not, it uses this rune buffer to produce an integer to assign to the corresponding field of the ISO8601Duration struct:

GO
 1const defaultDesignators = "YMWD"
 2const timeDesignators = "MHS"
 3
 4case stateTNumber:
 5    // ...
 6    if strings.ContainsRune(timeDesignators, b) {
 7        if curNumCount == 0 {
 8            return duration, wrapErr(MissingNumber, col)
 9        }
10        num := numBufferToNumber(numBuf)
11        switch b {
12        case 'H':
13            if duration.hour != 0 {
14                return duration, wrapErr(DuplicateDesignator, col)
15            }
16            duration.hour = float64(num)
17        case 'M':
18            if duration.minute != 0 {
19                return duration, wrapErr(DuplicateDesignator, col)
20            }
21            duration.minute = float64(num)
22        case 'S':
23            if duration.second != 0 {
24                return duration, wrapErr(DuplicateDesignator, col)
25            }
26            duration.second = float64(num)
27        }
28        curNumCount = 0
29        numBuf = [maxNumCount]rune{}
30        curState = stateTDesignator
31    } else {
32        return duration, wrapErr(UnknownDesignator, col)
33    }

numBufferToNumber is as simple as number conversion gets:

GO
 1func numBufferToNumber(buf [maxNumCount]rune) int64 {
 2	var i int
 3	for _, n := range buf {
 4		if n == 0 { // empty number (zero byte) in buffer, stop
 5			break
 6		}
 7		i = (i * 10) + int(n-'0')
 8	}
 9
10	return int64(i)
11}

I chose this way to omit the allocation of a string and the call to strconv.ParseInt by doing the work myself.

The whole machine

GO
  1func From(s string) (ISO8601Duration, error) {
  2	var duration ISO8601Duration
  3
  4	if len(s) == 0 {
  5		return duration, wrapErr(UnexpectedEof, 0)
  6	}
  7
  8	curState := stateStart
  9	var col uint8
 10	var curNumCount uint8
 11	var numBuf [maxNumCount]rune
 12
 13	r := strings.NewReader(s)
 14
 15	for {
 16		b, size, err := r.ReadRune()
 17		if err != nil {
 18			if err != io.EOF {
 19				return duration, wrapErr(UnexpectedReaderError, col)
 20			} else if curState == stateP {
 21				// being in stateP at the end (io.EOF) means we havent
 22				// encountered anything after the P, so there were no numbers
 23				// or states
 24				return duration, wrapErr(UnexpectedEof, col)
 25			} else if curState == stateNumber || curState == stateTNumber {
 26				// if we are in the state of Number or TNumber we had a number
 27				// but no designator at the end
 28				return duration, wrapErr(MissingDesignator, col)
 29			} else {
 30				curState = stateFin
 31			}
 32		}
 33		if size > 1 {
 34			return duration, wrapErr(UnexpectedNonAsciiRune, col)
 35		}
 36		col++
 37
 38		switch curState {
 39		case stateStart:
 40			if b != 'P' {
 41				return duration, wrapErr(MissingPDesignatorAtStart, col)
 42			}
 43			curState = stateP
 44		case stateP, stateDesignator:
 45			if b == 'T' {
 46				curState = stateT
 47			} else if unicode.IsDigit(b) {
 48				if curNumCount > maxNumCount {
 49					return duration, wrapErr(TooManyNumbersForDesignator, col)
 50				}
 51				numBuf[curNumCount] = b
 52				curNumCount++
 53				curState = stateNumber
 54			} else {
 55				return duration, wrapErr(MissingNumber, col)
 56			}
 57		case stateNumber:
 58			if unicode.IsDigit(b) {
 59				if curNumCount+1 > maxNumCount {
 60					return duration, wrapErr(TooManyNumbersForDesignator, col)
 61				}
 62				numBuf[curNumCount] = b
 63				curNumCount++
 64				curState = stateNumber
 65			} else if strings.ContainsRune(defaultDesignators, b) {
 66				if curNumCount == 0 {
 67					return duration, wrapErr(MissingNumber, col)
 68				}
 69				num := numBufferToNumber(numBuf)
 70				switch b {
 71				case 'Y':
 72					if duration.year != 0 {
 73						return duration, wrapErr(DuplicateDesignator, col)
 74					}
 75					duration.year = float64(num)
 76				case 'M':
 77					if duration.month != 0 {
 78						return duration, wrapErr(DuplicateDesignator, col)
 79					}
 80					duration.month = float64(num)
 81				case 'W':
 82					if r.Len() != 0 {
 83						return duration, wrapErr(NoDesignatorsAfterWeeksAllowed, col)
 84					}
 85					duration.week = float64(num)
 86				case 'D':
 87					if duration.day != 0 {
 88						return duration, wrapErr(DuplicateDesignator, col)
 89					}
 90					duration.day = float64(num)
 91				}
 92				curNumCount = 0
 93				numBuf = [maxNumCount]rune{}
 94				curState = stateDesignator
 95			} else {
 96				return duration, wrapErr(UnknownDesignator, col)
 97			}
 98		case stateT, stateTDesignator:
 99			if unicode.IsDigit(b) {
100				if curNumCount > maxNumCount {
101					return duration, wrapErr(TooManyNumbersForDesignator, col)
102				}
103				numBuf[curNumCount] = b
104				curNumCount++
105				curState = stateTNumber
106			} else {
107				return duration, wrapErr(MissingNumber, col)
108			}
109		case stateTNumber:
110			if unicode.IsDigit(b) {
111				if curNumCount+1 > maxNumCount {
112					return duration, wrapErr(TooManyNumbersForDesignator, col)
113				}
114				numBuf[curNumCount] = b
115				curNumCount++
116				curState = stateTNumber
117			} else if strings.ContainsRune(timeDesignators, b) {
118				if curNumCount == 0 {
119					return duration, wrapErr(MissingNumber, col)
120				}
121				num := numBufferToNumber(numBuf)
122				switch b {
123				case 'H':
124					if duration.hour != 0 {
125						return duration, wrapErr(DuplicateDesignator, col)
126					}
127					duration.hour = float64(num)
128				case 'M':
129					if duration.minute != 0 {
130						return duration, wrapErr(DuplicateDesignator, col)
131					}
132					duration.minute = float64(num)
133				case 'S':
134					if duration.second != 0 {
135						return duration, wrapErr(DuplicateDesignator, col)
136					}
137					duration.second = float64(num)
138				}
139				curNumCount = 0
140				numBuf = [maxNumCount]rune{}
141				curState = stateTDesignator
142			} else {
143				return duration, wrapErr(UnknownDesignator, col)
144			}
145		case stateFin:
146			return duration, nil
147		}
148	}
149}

Go time Compatibility

Since the whole usecase for this library is to interact with the go time package, it needs conversion to do exactly that.

Apply

As the name suggests this function takes a time.Time, applies the value encoded in ISO8601Duration to it and returns a resulting time.Time instance.

GO
1func (i ISO8601Duration) Apply(t time.Time) time.Time {
2	newT := t.AddDate(int(i.year), int(i.month), int(i.day+i.week*7))
3	d := time.Duration(
4		(i.hour * float64(time.Hour)) +
5			(i.minute * float64(time.Minute)) +
6			(i.second * float64(time.Second)),
7	)
8	return newT.Add(d)
9}

Duration

Duration is a bit more complicated. I had to approximate some values (like daysPerYear and daysPerMonth) which of course doesn’t account for the whole lap year/lap day fuckery.

GO
 1func (i ISO8601Duration) Duration() time.Duration {
 2	const (
 3		nsPerSecond  = int64(time.Second)
 4		nsPerMinute  = int64(time.Minute)
 5		nsPerHour    = int64(time.Hour)
 6		nsPerDay     = int64(24 * time.Hour)
 7		nsPerWeek    = int64(7 * 24 * time.Hour)
 8		daysPerYear  = 365.2425
 9		daysPerMonth = 30.436875
10	)
11
12	var ns int64
13
14	ns += int64(i.year * daysPerYear * float64(nsPerDay))
15	ns += int64(i.month * daysPerMonth * float64(nsPerDay))
16	ns += int64(i.week * float64(nsPerWeek))
17	ns += int64(i.day * float64(nsPerDay))
18	ns += int64(i.hour * float64(nsPerHour))
19	ns += int64(i.minute * float64(nsPerMinute))
20	ns += int64(i.second * float64(nsPerSecond))
21
22	return time.Duration(ns)
23}

Serializing via the Stringer interface

GO
1// Stringer is implemented by any value that has a String method,
2// which defines the “native” format for that value.
3// The String method is used to print values passed as an operand
4// to any format that accepts a string or to an unformatted printer
5// such as [Print].
6type Stringer interface {
7	String() string
8}

Implementing this is fairly easy: at a high level: It consists of checking for each field whether its 0, if not write the value (nn) and its designator (YMDHMS). Edgecases are:

  • all fields are zero -> write P0D
  • time fields are set, prefix with T
  • field week is non zero -> write only the week PnnW, nothing else
GO
 1func (i ISO8601Duration) String() string {
 2	b := strings.Builder{}
 3	b.WriteRune('P')
 4
 5	// If the number of years, months, days, hours, minutes or seconds in any of these expressions equals
 6	// zero, the number and the corresponding designator may be absent; however, at least one number
 7	// and its designator shall be present
 8	if i.year == 0 && i.month == 0 && i.week == 0 && i.day == 0 && i.hour == 0 && i.minute == 0 && i.second == 0 {
 9		b.WriteString("0D")
10		return b.String()
11	}
12
13	if i.week > 0 {
14		b.WriteString(strconv.FormatFloat(i.week, 'g', -1, 64))
15		b.WriteRune('W')
16		return b.String()
17	}
18
19	if i.year > 0 {
20		b.WriteString(strconv.FormatFloat(i.year, 'g', -1, 64))
21		b.WriteRune('Y')
22	}
23	if i.month > 0 {
24		b.WriteString(strconv.FormatFloat(i.month, 'g', -1, 64))
25		b.WriteRune('M')
26	}
27	if i.day > 0 {
28		b.WriteString(strconv.FormatFloat(i.day, 'g', -1, 64))
29		b.WriteRune('D')
30	}
31
32	// The designator [T] shall be absent if all of the time components are absent.
33	if i.hour > 0 || i.minute > 0 || i.second > 0 {
34		b.WriteRune('T')
35
36		if i.hour > 0 {
37			b.WriteString(strconv.FormatFloat(i.hour, 'g', -1, 64))
38			b.WriteRune('H')
39		}
40
41		if i.minute > 0 {
42			b.WriteString(strconv.FormatFloat(i.minute, 'g', -1, 64))
43			b.WriteRune('M')
44		}
45
46		if i.second > 0 {
47			b.WriteString(strconv.FormatFloat(i.second, 'g', -1, 64))
48			b.WriteRune('S')
49		}
50	}
51
52	return b.String()
53}

Extensive testing

Since most of my critisism of the existing libraries were either no spec compliance and or accepting weird inputs I made sure to do extensive testing before publishing the library.

Edgecases

Some of these are inputs I tried throwing at other libraries to test their compliance and how in depth their error messages are.

GO
 1// TestDurationErr makes sure all expected edgecases are implemented correctly
 2func TestDurationErr(t *testing.T) {
 3	cases := []string{
 4		"",        // UnexpectedEof
 5		"P",       // UnexpectedEof
 6		"è",       // UnexpectedNonAsciiRune
 7		"P1",      // MissingDesignator
 8		"P1A",     // UnknownDesignator
 9		"P12D12D", // DuplicateDesignator
10		"P1YD",    // MissingNumber
11		"P111Y",   // TooManyNumbersForDesignator
12		"Z",       // MissingPDesignatorAtStart
13		"P15W2D",  // NoDesignatorsAfterWeeksAllowed
14	}
15
16	for _, i := range cases {
17		t.Run(i, func(t *testing.T) {
18			_, err := From(i)
19			if assert.Error(t, err) {
20				fmt.Println(err)
21			}
22		})
23	}
24}
Terminal
$ go test ./... -v
=== RUN TestDurationErr
=== RUN TestDurationErr/#00
ISO8601DurationError: Unexpected EOF in duration format string, at col: 0
=== RUN TestDurationErr/P
ISO8601DurationError: Unexpected EOF in duration format string, at col: 1
=== RUN TestDurationErr/è
ISO8601DurationError: Unexpected non ascii component in duration format string, at col: 0
=== RUN TestDurationErr/P1
ISO8601DurationError: Missing unit designator, at col: 2
=== RUN TestDurationErr/P1A
ISO8601DurationError: Unknown designator, expected YMWD or after a T, HMS, at col: 3
=== RUN TestDurationErr/P12D12D
ISO8601DurationError: Duplicate designator, at col: 7
=== RUN TestDurationErr/P1YD
ISO8601DurationError: Missing number specifier before unit designator, at col: 4
=== RUN TestDurationErr/P111Y
ISO8601DurationError: Only 2 numbers before any designator allowed, at col: 4
=== RUN TestDurationErr/Z
ISO8601DurationError: Missing [P] designator at the start of the duration format string, at col: 1
=== RUN TestDurationErr/P15W2D
ISO8601DurationError: After [W] designator, no other numbers and designators are allowed, at col: 4

Happy paths

Each testcase crafted so I hit all branches and possibilites the spec defines.

GO
 1var testcases = []struct {
 2	str string
 3	dur ISO8601Duration
 4}{
 5	{"P0D", ISO8601Duration{}},
 6	{"PT15H", ISO8601Duration{hour: 15}},
 7	{"P1W", ISO8601Duration{week: 1}},
 8	{"P15W", ISO8601Duration{week: 15}},
 9	{"P15Y", ISO8601Duration{year: 15}},
10	{"P15Y3M", ISO8601Duration{year: 15, month: 3}},
11	{"P15Y3M41D", ISO8601Duration{year: 15, month: 3, day: 41}},
12	{"PT15M", ISO8601Duration{minute: 15}},
13	{"PT15M10S", ISO8601Duration{minute: 15, second: 10}},
14	{
15		"P3Y6M4DT12H30M5S",
16		ISO8601Duration{
17			year:   3,
18			month:  6,
19			day:    4,
20			hour:   12,
21			minute: 30,
22			second: 5,
23		},
24	},
25}
26
27func TestDurationStringer(t *testing.T) {
28	for _, i := range testcases {
29		t.Run(i.str, func(t *testing.T) {
30			stringified := i.dur.String()
31			assert.Equal(t, i.str, stringified)
32		})
33	}
34}
35
36func TestDuration(t *testing.T) {
37	for _, i := range testcases {
38		t.Run(i.str, func(t *testing.T) {
39			parsed, err := From(i.str)
40			assert.NoError(t, err)
41			assert.Equal(t, i.dur, parsed)
42		})
43	}
44}
45
46func TestBiliteral(t *testing.T) {
47	for _, i := range testcases {
48		t.Run(i.str, func(t *testing.T) {
49			parsed, err := From(i.str)
50			assert.NoError(t, err)
51			assert.Equal(t, i.dur, parsed)
52			stringified := parsed.String()
53			assert.Equal(t, i.str, stringified)
54		})
55	}
56}