Background

Continuing with the theme of the last post, exploring Go and JSON unmarshalling, I thought I would write about unmarshalling time.Duration.

Go’s encoding/json package supports unmarshalling JSON into the following types:

bool, for JSON booleans
float64, for JSON numbers
string, for JSON strings
[]interface{}, for JSON arrays
map[string]interface{}, for JSON objects
nil for JSON null

With that in mind, how can you handle other types like time.Duration?

What is Special About “time.Duration”?

In the same vein as the last post, the solution relies on a custom struct and use Go’s UnmarshalJSON() interface. time.Duration presents an extra challenge because it can be represented by a string or a number.

time.Duration is used to store the elapsed time between two points in time. This value is stored natively in nanoseconds as an int64.

A string representation of 1 second is "1s". To store "1s" as a time.Duration, we can write:

duration, _ := time.ParseDuration("1s")
	
// Prints "Duration: 1000000000 nanoseconds"
fmt.Printf("Duration: %#v nanoseconds\n", duration)

Equally, we could accept a value of 5000000000 nanoseconds (5 seconds):

duration = time.Duration(5 * time.Second)

// Prints "Duration: 5 seconds"
fmt.Printf("Duration: %.0f seconds", duration.Seconds())

Go Playground example.

If we want to retain this flexibility when unmarshalling a JSON string, our custom UnmarshalJSON() method has to be able to handle both the string and float64 types. Note that these are two of the types natively supported by json.Unmarshal().

Custom Struct Type and UnmarshalJSON() Method

Before writing a custom UnmarshalJSON() method, create the custom struct type:

type Duration struct {
	time.Duration
}

With that formality undertaken, go ahead and create the unmarshaller:

func (duration *Duration) UnmarshalJSON(b []byte) error {
	var unmarshalledJson interface{}

	err := json.Unmarshal(b, &unmarshalledJson)
	if err != nil {
		return err
	}

	switch value := unmarshalledJson.(type) {
	case float64:
		duration.Duration = time.Duration(value)
	case string:
		duration.Duration, err = time.ParseDuration(value)
		if err != nil {
			return err
		}
	default:
		return fmt.Errorf("invalid duration: %#v", unmarshalledJson)
	}

	return nil
}

Although this unmarshaller is not disimilar to the NullBool unmarshaller we wrote last time, there are some differences to unpack.

Notice that instead of unmarshalling directly into a specific type, the code unmarshalls into an interface{}. Empty interfaces in Go can hold any type.

With the JSON unmarshalled, we want to populate the Duration struct. If the supplied JSON contains a float64, it can be cast to time.Duration. If the supplied JSON contains a string, it can be parsed with time.ParseDuration().

To determine whether we should be casting or parsing the supplied value, the special .(type) operator is employed. .(type) returns the underlying concrete type of an interface{}.

Idiosyncratically the Go compiler insists that .(type) can only be used in a switch statement.

Using the Unmarshaller

Create a struct to represent the JSON data:

type JsonInput struct {
	Id 	    int
	Author      string
	ReadingTime Duration
}

Then, unmarshal as usual:

jsonData := []byte(`
{
    "Id": 0,
    "Author": "Catriona Trottenbrow",
    "ReadingTime": 300000000000
}`)

var input = JsonInput{}

err := json.Unmarshal(jsonData, &input)
if err != nil {
	fmt.Print(err)
}

Conclusion

Click here for the Go Playground example.

Go 21 will make it unnecessary to use custom struct types and UnmarshalJSON() methods to unmarshal time.Duration.

This technique will no doubt be useful in the meantime with time.Duration and other special data types.

References

  1. pkg.go.dev encoding/json#Unmarshal
  2. pkg.go.dev time
  3. The Go Programming Language Specification - Type switches

  1. See, for instance, Go issue 10275 ↩︎