Deserializing JSON array strings into custom Golang structs
Go has been one of my favorite languages to use for side projects, although I have wrestled with some of the limitations of the type system in the past. I recently forced myself to learn the generics feature added in Go 1.18 and have been appreciating the extra flexibility it gives.
Today, I found myself wanting to parse a JSON list of tuples (encoded as nested arrays) into an array of strongly-typed structs. In the past I would have parsed into an array of interface{}
and used casting / conversion methods to massage the data into the right format.
I had the sense that this could be better implemented as a generic Tuple
data type with a custom UnmarshalJSON
function. My outer array could be parsed into a list of Tuple
s, and each Tuple
would deserialize the corresponding nested array into its generic types. The problem with this is that while Go's encoding/json
library makes it easy to map JSON object keys to struct fields, you don't get an equivalently simple way to map indexed array entries into a struct.
Thankfully json.RawMessage
seems to have been designed to solve this problem. In the UnmarshalJSON
function, I parse the subarrays into []json.RawMessage
which effectively "stops" the unmarshal process from automatically determining the underlying type of the nested array. Then I manually unmarshal each json.RawMessage
entry in the subarray into its corresponding generic type.
Probably easier to read the Tuple implementation itself:
type Tuple[T any, U any] struct {
One T
Two U
}
func (t *Tuple[T, U]) UnmarshalJSON(data []byte) error {
var (
item []json.RawMessage
err error
)
if err = json.Unmarshal(data, &item); err != nil {
return err
}
if err = json.Unmarshal(item[0], &t.One); err != nil {
return err
}
if err = json.Unmarshal(item[1], &t.Two); err != nil {
return err
}
return nil
}
This generic 2-item Tuple
is quite versatile! I can quickly map a parsed format to arbitrary nested array inputs:
func TestTupleUnmarshal(t *testing.T) {
var tuplesIntStr []Tuple[int, string]
str := `[[1, "abcd"], [2, "abba"], [3, "foobar"]]`
err := json.Unmarshal([]byte(str), &tuplesIntStr)
require.NoError(t, err)
require.Equal(t, tuplesIntStr[0], Tuple[int, string]{One: 1, Two: "abcd"})
require.Equal(t, tuplesIntStr[1], Tuple[int, string]{One: 2, Two: "abba"})
require.Equal(t, tuplesIntStr[2], Tuple[int, string]{One: 3, Two: "foobar"})
var tuplesStrFloat []Tuple[string, float64]
str = `[["φ", 1.618], ["π", 3.14], ["e", 2.718]]`
err = json.Unmarshal([]byte(str), &tuplesStrFloat)
require.NoError(t, err)
require.Equal(t, tuplesStrFloat[0], Tuple[string, float64]{One: "φ", Two: 1.618})
require.Equal(t, tuplesStrFloat[1], Tuple[string, float64]{One: "π", Two: 3.14})
require.Equal(t, tuplesStrFloat[2], Tuple[string, float64]{One: "e", Two: 2.718})
}
(The require functions in this example are from the excellent github.com/stretchr/testify/require library)
This kind of thing has been far easier to do in languages with more expressive type systems, but I do appreciate the straightforward approach to language design Go has chosen. I wasn't sure if I'd find Go generics awkward to use or coupled with lots of limitations but that hasn't seemed to be the case yet, and they do seem to avoid the need to manually wrangle interface{}
s which has admittedly been some of my least favorite Go code to write.