Go: finally a practical solution for undefined fields

The problem It is well known that undefined doesn't exist in Go. There are only zero values. For years, Go developers have been struggling with the JSON struct tag omitempty to handle those use-cases. omitempty didn't cover all cases very well and can be fussy. Indeed, the definition of a value being "empty" isn't very clear. When marshaling: Slices and maps are empty if they're nil or have a length of zero. A pointer is empty if nil. A struct is never empty. A string is empty if it has a length of zero. Other types are empty if they have their zero-value. And when unmarshaling... it's impossible to tell the difference between a missing field in the input and a present field having Go's zero-value. There are so many different cases to keep in mind when working with omitempty. It's inconvenient and error-prone. The workaround Go developers have been relying on a workaround: using pointers everywhere for fields that can be absent, in combination with the omitempty tag. It makes it easier to handle both marshaling and unmarshaling: When marshaling, you know a nil field will never be visible in the output. When unmarshaling, you know a field wasn't present in the input if it's nil. Except... that's not entirely true. There are still use-cases that are not covered by this workaround. When you need to handle nullable values (where null is actually value that your service accepts), you're back to square one: when unmarshaling, it's impossible to tell if the input contains the field or not. when marshaling, you cannot use omitempty, otherwise nil values won't be present in the output. Using pointers is also error-prone and not very convenient. They require many nil-checks and dereferencing everywhere. The solution With the introduction of the omitzero tag in Go 1.24, we finally have all the tools we need to build a clean solution. omitzero is way simpler than omitempty: if the field has its zero-value, it is omitted. It also works for structures, which are considered "zero" if all their fields have their zero-value. For example, it is now simple as that to omit a time.Time field: type MyStruct struct{ SomeTime time.Time `json:",omitzero"` } Done are the times of 0001-01-01T00:00:00Z! However, there are still some issues that are left unsolved: Handling nullable values when marshaling. Differentiating between a zero value and undefined value. Differentiating between a null and absent value when unmarshaling. Undefined wrapper type Because omitzero handles zero structs gracefully, we can build a new wrapper type that will solve all of this for us! The trick is to play with the zero value of a struct in combination with the omitzero tag. type Undefined[T any] struct { Val T Present bool } If Present is true, then the structure will not have its zero value. We will therefore know that the field is present (not undefined)! Now, we need to add support for the json.Marshaler and json.Unmarshaler interfaces so our type will behave as expected: func (u *Undefined[T]) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &u.Val); err != nil { return fmt.Errorf("Undefined: couldn't unmarshal JSON: %w", err) } u.Present = true return nil } func (u Undefined[T]) MarshalJSON() ([]byte, error) { data, err := json.Marshal(u.Val) if err != nil { return nil, fmt.Errorf("Undefined: couldn't JSON marshal: %w", err) } return data, nil } func (u Undefined[T]) IsZero() bool { return !u.Present } Because UnmarshalJSON is never called if the input doesn't contain a matching field, we know that Present will remain false. But if it is present, we unmarshal the value and always set Present to true. For marshaling, we don't want to output the wrapper structure, so we just marshal the value. The field will be omitted if not present thanks to the omitzero struct tag. As a bonus, we also implemented IsZero(), which is supported by the standard JSON library: If the field type has an IsZero() bool method, that will be used to determine whether the value is zero. The generic parameter T allows us to use this wrapper with absolutely anything. We now have a practical and unified way to handle undefined for all types in Go! Going further We could go further and apply the same logic for database scanning. This way it will be possible to tell if a field was selected or not. You can find a full implementation of the Undefined type in the Goyave framework, alongside many other incredibly useful tools and features. Happy coding!

Apr 23, 2025 - 16:50
 0
Go: finally a practical solution for undefined fields

The problem

It is well known that undefined doesn't exist in Go. There are only zero values.

For years, Go developers have been struggling with the JSON struct tag omitempty to handle those use-cases.

omitempty didn't cover all cases very well and can be fussy. Indeed, the definition of a value being "empty" isn't very clear.

When marshaling:

  • Slices and maps are empty if they're nil or have a length of zero.
  • A pointer is empty if nil.
  • A struct is never empty.
  • A string is empty if it has a length of zero.
  • Other types are empty if they have their zero-value.

And when unmarshaling... it's impossible to tell the difference between a missing field in the input and a present field having Go's zero-value.

There are so many different cases to keep in mind when working with omitempty. It's inconvenient and error-prone.

The workaround

Go developers have been relying on a workaround: using pointers everywhere for fields that can be absent, in combination with the omitempty tag. It makes it easier to handle both marshaling and unmarshaling:

  • When marshaling, you know a nil field will never be visible in the output.
  • When unmarshaling, you know a field wasn't present in the input if it's nil.

Except... that's not entirely true. There are still use-cases that are not covered by this workaround. When you need to handle nullable values (where null is actually value that your service accepts), you're back to square one:

  • when unmarshaling, it's impossible to tell if the input contains the field or not.
  • when marshaling, you cannot use omitempty, otherwise nil values won't be present in the output.

Using pointers is also error-prone and not very convenient. They require many nil-checks and dereferencing everywhere.

The solution

With the introduction of the omitzero tag in Go 1.24, we finally have all the tools we need to build a clean solution.

omitzero is way simpler than omitempty: if the field has its zero-value, it is omitted. It also works for structures, which are considered "zero" if all their fields have their zero-value.

For example, it is now simple as that to omit a time.Time field:

type MyStruct struct{
    SomeTime time.Time `json:",omitzero"`
}

Done are the times of 0001-01-01T00:00:00Z!

However, there are still some issues that are left unsolved:

  • Handling nullable values when marshaling.
  • Differentiating between a zero value and undefined value.
  • Differentiating between a null and absent value when unmarshaling.

Undefined wrapper type

Because omitzero handles zero structs gracefully, we can build a new wrapper type that will solve all of this for us!

The trick is to play with the zero value of a struct in combination with the omitzero tag.

type Undefined[T any] struct {
    Val     T
    Present bool
}

If Present is true, then the structure will not have its zero value. We will therefore know that the field is present (not undefined)!

Now, we need to add support for the json.Marshaler and json.Unmarshaler interfaces so our type will behave as expected:

func (u *Undefined[T]) UnmarshalJSON(data []byte) error {
    if err := json.Unmarshal(data, &u.Val); err != nil {
        return fmt.Errorf("Undefined: couldn't unmarshal JSON: %w", err)
    }

    u.Present = true
    return nil
}

func (u Undefined[T]) MarshalJSON() ([]byte, error) {
    data, err := json.Marshal(u.Val)
    if err != nil {
        return nil, fmt.Errorf("Undefined: couldn't JSON marshal: %w", err)
    }
    return data, nil
}

func (u Undefined[T]) IsZero() bool {
    return !u.Present
}

Because UnmarshalJSON is never called if the input doesn't contain a matching field, we know that Present will remain false. But if it is present, we unmarshal the value and always set Present to true.

For marshaling, we don't want to output the wrapper structure, so we just marshal the value. The field will be omitted if not present thanks to the omitzero struct tag.

As a bonus, we also implemented IsZero(), which is supported by the standard JSON library:

If the field type has an IsZero() bool method, that will be used to determine whether the value is zero.

The generic parameter T allows us to use this wrapper with absolutely anything. We now have a practical and unified way to handle undefined for all types in Go!

Going further

We could go further and apply the same logic for database scanning. This way it will be possible to tell if a field was selected or not.

You can find a full implementation of the Undefined type in the Goyave framework, alongside many other incredibly useful tools and features.

Happy coding!