Custom JSON Marshaling in Go
2023-02-19
Go’s standard library has a convenient way of handling JSON verification and default values through the MarshalerMarshaler and UnmarshalerUnmarshaler interfaces. This means we don’t necessarily need separate methods to handle this data verification/manipulation.
Let’s pretend our system can’t allow users that are under the age 13 and we need a default timezone.
var ( ErrTooYoung = fmt.Errorf("too young") ErrTZNotFound = fmt.Errorf("invalid timezone, must be one of %v", timezones))type TimeZone stringvar timezones = [...]TimeZone{"UTC", "America/New_York"}type User struct { Name string `json:"name"` Age int `json:"age"` TZ TimeZone `json:"timezone"`}func (t TimeZone) Validate() error { for _, v := range timezones { if v == t { return nil } } return ErrTZNotFound}func (u *User) UnmarshalJSON(b []byte) error { type UserAlias User ua := &struct { *UserAlias }{ UserAlias: (*UserAlias)(u), } if err := json.Unmarshal(b, ua); err != nil { return err } if u.Age <= 13 { return ErrTooYoung } if u.TZ == "" { u.TZ = "UTC" } if err := u.TZ.Validate(); err != nil { return err } return nil}
var ( ErrTooYoung = fmt.Errorf("too young") ErrTZNotFound = fmt.Errorf("invalid timezone, must be one of %v", timezones))type TimeZone stringvar timezones = [...]TimeZone{"UTC", "America/New_York"}type User struct { Name string `json:"name"` Age int `json:"age"` TZ TimeZone `json:"timezone"`}func (t TimeZone) Validate() error { for _, v := range timezones { if v == t { return nil } } return ErrTZNotFound}func (u *User) UnmarshalJSON(b []byte) error { type UserAlias User ua := &struct { *UserAlias }{ UserAlias: (*UserAlias)(u), } if err := json.Unmarshal(b, ua); err != nil { return err } if u.Age <= 13 { return ErrTooYoung } if u.TZ == "" { u.TZ = "UTC" } if err := u.TZ.Validate(); err != nil { return err } return nil}
You might be wondering why we declare an inline struct called UserAliasUserAlias. The problem is if we don’t declare this new type and you try to unmarshal the data into uu it will create a recursive never ending call to the unmashal method. Instead, we create the inline struct that will unmarshal only to our local type, but will unmarshal into uu the way we expect. Once we unmarshal our data, we then run any verifications/defaults against our struct. In this case, we just check if the user age is 13 or less and check the timezone value.
You could do this in solely in separate validation methods/functions but that means you would need to call them manually every time you want to unmarshal this type.
The marshaler is similar. One catch with the marshaler however, is that to just call json.Marshal(userStruct)json.Marshal(userStruct) the method must take UserUser as the copy value instead of a pointer. If you want to use a pointer you will need to call json.Marshal(&userStruct)json.Marshal(&userStruct). Here we apply the same logic to marshaling the data as we did above to unmarshaling the data. You also need to make sure you marshal the local type instead of the actual type because you will run into the same infinite recursion as with the unmarshaler.
func (u User) MarshalJSON() ([]byte, error) { type UserAlias User ua := &struct { UserAlias }{ UserAlias: (UserAlias)(u), } if ua.Age <= 13 { return nil, ErrTooYoung } if ua.TZ == "" { u.TZ = "UTC" } if err := ua.TZ.Validate(); err != nil { return nil, ErrTZNotFound } return json.Marshal(ua)}
func (u User) MarshalJSON() ([]byte, error) { type UserAlias User ua := &struct { UserAlias }{ UserAlias: (UserAlias)(u), } if ua.Age <= 13 { return nil, ErrTooYoung } if ua.TZ == "" { u.TZ = "UTC" } if err := ua.TZ.Validate(); err != nil { return nil, ErrTZNotFound } return json.Marshal(ua)}
The final code (with the validations in their own method) would look like this:
package mainimport ( "encoding/json" "errors" "fmt" "log")var ( ErrTooYoung = fmt.Errorf("too young") ErrTZNotFound = fmt.Errorf("invalid timezone, must be one of %v", timezones))type TimeZone stringvar timezones = [...]TimeZone{"UTC", "America/New_York"}type User struct { Name string `json:"name"` Age int `json:"age"` TZ TimeZone `json:"timezone"`}func (t TimeZone) Validate() error { for _, v := range timezones { if v == t { return nil } } return ErrTZNotFound}func (u *User) Validate() error { if u.Age <= 13 { return ErrTooYoung } return u.TZ.Validate()}func (u *User) UnmarshalJSON(b []byte) error { type UserAlias User ua := &struct { *UserAlias }{ UserAlias: (*UserAlias)(u), } if err := json.Unmarshal(b, ua); err != nil { return fmt.Errorf("%w", err) } if u.TZ == "" { u.TZ = "UTC" } return u.Validate()}func (u User) MarshalJSON() ([]byte, error) { type UserAlias User ua := &struct { UserAlias }{ UserAlias: (UserAlias)(u), } if u.TZ == "" { u.TZ = "UTC" } if err := u.Validate(); err != nil { return nil, fmt.Errorf("%w", err) } return json.Marshal(ua)}func main() { data := `{"name": "John", "age": 12, "timezone": "UTC"}` var user User if err := json.Unmarshal([]byte(data), &user); err != nil { log.Fatal(err) // should error on user being too young } u := User{ Name: "John", Age: 14, TZ: "utc", // should error on incorrect timezone } b, err := json.Marshal(u) if err != nil { log.Fatal(errors.Unwrap(err)) } fmt.Printf("%#v", user) fmt.Println(string(b))}
package mainimport ( "encoding/json" "errors" "fmt" "log")var ( ErrTooYoung = fmt.Errorf("too young") ErrTZNotFound = fmt.Errorf("invalid timezone, must be one of %v", timezones))type TimeZone stringvar timezones = [...]TimeZone{"UTC", "America/New_York"}type User struct { Name string `json:"name"` Age int `json:"age"` TZ TimeZone `json:"timezone"`}func (t TimeZone) Validate() error { for _, v := range timezones { if v == t { return nil } } return ErrTZNotFound}func (u *User) Validate() error { if u.Age <= 13 { return ErrTooYoung } return u.TZ.Validate()}func (u *User) UnmarshalJSON(b []byte) error { type UserAlias User ua := &struct { *UserAlias }{ UserAlias: (*UserAlias)(u), } if err := json.Unmarshal(b, ua); err != nil { return fmt.Errorf("%w", err) } if u.TZ == "" { u.TZ = "UTC" } return u.Validate()}func (u User) MarshalJSON() ([]byte, error) { type UserAlias User ua := &struct { UserAlias }{ UserAlias: (UserAlias)(u), } if u.TZ == "" { u.TZ = "UTC" } if err := u.Validate(); err != nil { return nil, fmt.Errorf("%w", err) } return json.Marshal(ua)}func main() { data := `{"name": "John", "age": 12, "timezone": "UTC"}` var user User if err := json.Unmarshal([]byte(data), &user); err != nil { log.Fatal(err) // should error on user being too young } u := User{ Name: "John", Age: 14, TZ: "utc", // should error on incorrect timezone } b, err := json.Marshal(u) if err != nil { log.Fatal(errors.Unwrap(err)) } fmt.Printf("%#v", user) fmt.Println(string(b))}
Update: Added error unwrapping when marshaling to get just the relevant error.