Go’s standard library has a convenient way of handling JSON verification and default values through the Marshaler
and Unmarshaler
interfaces. This means we don’t necessarily need separate methods to handle this data verification/manipulation.
Unmarshaler
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 string
var 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 UserAlias
. The problem is if we don’t declare this new type and you try to unmarshal the data into u
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 u
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.
Marshaler
The marshaler is similar. One catch with the marshaler however, is that to just call json.Marshal(userStruct)
the method must take User
as the copy value instead of a pointer. If you want to use a pointer you will need to call 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)
}
Complete Code
The final code (with the validations in their own method) would look like this:
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
)
var (
ErrTooYoung = fmt.Errorf("too young")
ErrTZNotFound = fmt.Errorf("invalid timezone, must be one of %v", timezones)
)
type TimeZone string
var 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.