From a04b3337e36832d3a1803e1bd34baade92b0a2f2 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Wed, 24 Jun 2026 09:24:24 -0700 Subject: [PATCH] feat: honor the omitzero struct-tag option encoding/json gained the `omitzero` field option in Go 1.24: a field is omitted when it holds the zero value for its type, as determined by an `IsZero() bool` method if present, otherwise by reflect.Value.IsZero(). The tag parser already recognizes the option, but it was silently ignored, so a zero-valued field tagged `,omitzero` was always encoded. Honor it in field.Omit, independently of `omitempty`. A new isZeroValue helper mirrors encoding/json's semantics: a type's own IsZero takes precedence, a nil pointer/interface is treated as zero up front (so a typed-nil whose type carries an IsZero method is not dereferenced), and otherwise reflect.Value.IsZero() decides. Unlike omitempty's isEmptyValue, it does not treat a non-nil empty slice, map, or string as omittable. Co-Authored-By: Claude Opus 4.8 --- omitzero_test.go | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ types.go | 38 ++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 omitzero_test.go diff --git a/omitzero_test.go b/omitzero_test.go new file mode 100644 index 00000000..14714b3d --- /dev/null +++ b/omitzero_test.go @@ -0,0 +1,63 @@ +package msgpack_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/vmihailenco/msgpack/v5" +) + +// zeroByMethod has a value-receiver IsZero, exercising the omitzero path that +// defers to a type's own IsZero rather than reflect's structural zero check. +type zeroByMethod struct{ n int } + +func (z zeroByMethod) IsZero() bool { return z.n == 0 } + +// TestOmitZero checks that the `omitzero` struct-tag option is honored, matching +// encoding/json (Go 1.24+): a field is omitted when it holds the zero value for +// its type, where a type's own IsZero method takes precedence. This is distinct +// from `omitempty`, which also drops non-nil empty slices, maps, and strings. +func TestOmitZero(t *testing.T) { + type S struct { + Keep int `msgpack:"keep"` // no option: always encoded + OmitInt int `msgpack:"omit_int,omitzero"` // dropped when 0 + OmitStr string `msgpack:"omit_str,omitzero"` // dropped when "" + OmitBool bool `msgpack:"omit_bool,omitzero"` // dropped when false + OmitObj zeroByMethod `msgpack:"omit_obj,omitzero"` // dropped when IsZero() + OmitPtr *time.Time `msgpack:"omit_ptr,omitzero"` // dropped when nil + } + + encodeToMap := func(t *testing.T, v any) map[string]any { + t.Helper() + b, err := msgpack.Marshal(v) + require.NoError(t, err) + var m map[string]any + require.NoError(t, msgpack.Unmarshal(b, &m)) + return m + } + + omitzeroKeys := []string{"omit_int", "omit_str", "omit_bool", "omit_obj", "omit_ptr"} + + // Zero value: every omitzero field is dropped; the untagged field stays. + // The nil *time.Time also exercises the nil-pointer guard: its element type + // has an IsZero method, which must not be invoked on the nil pointer. + m := encodeToMap(t, S{}) + require.Contains(t, m, "keep", "field without omitzero must always be encoded") + for _, k := range omitzeroKeys { + require.NotContains(t, m, k, "omitzero field %q must be omitted at the zero value", k) + } + + // Non-zero value: every omitzero field is encoded. + now := time.Unix(1, 0) + m = encodeToMap(t, S{ + OmitInt: 7, + OmitStr: "x", + OmitBool: true, + OmitObj: zeroByMethod{n: 1}, + OmitPtr: &now, + }) + for _, k := range omitzeroKeys { + require.Contains(t, m, k, "omitzero field %q must be encoded when non-zero", k) + } +} diff --git a/types.go b/types.go index d212e098..665f3bf4 100644 --- a/types.go +++ b/types.go @@ -95,6 +95,7 @@ type field struct { name string index []int omitEmpty bool + omitZero bool // honor the encoding/json 1.24 `omitzero` tag option. } func (f *field) Omit(e *Encoder, strct reflect.Value) bool { @@ -102,6 +103,13 @@ func (f *field) Omit(e *Encoder, strct reflect.Value) bool { if !ok { return true } + // `omitzero` omits the field when it holds the zero value for its + // type, matching encoding/json (Go 1.24+). It is independent of + // `omitempty`: a custom IsZero takes precedence, and unlike omitempty it + // does not treat a non-nil empty slice/map as omittable. + if f.omitZero && isZeroValue(v) { + return true + } forced := e.flags&omitEmptyFlag != 0 return (f.omitEmpty || forced) && e.isEmptyValue(v) } @@ -128,6 +136,7 @@ type fields struct { AsArray bool hasOmitEmpty bool + hasOmitZero bool // any field carries the `omitzero` tag option. } func newFields(typ reflect.Type) *fields { @@ -145,6 +154,9 @@ func (fs *fields) Add(field *field) { if field.omitEmpty { fs.hasOmitEmpty = true } + if field.omitZero { + fs.hasOmitZero = true + } } func (fs *fields) warnIfFieldExists(name string) { @@ -155,7 +167,7 @@ func (fs *fields) warnIfFieldExists(name string) { func (fs *fields) OmitEmpty(e *Encoder, strct reflect.Value) []*field { forced := e.flags&omitEmptyFlag != 0 - if !fs.hasOmitEmpty && !forced { + if !fs.hasOmitEmpty && !fs.hasOmitZero && !forced { return fs.List } @@ -202,6 +214,7 @@ func getFields(typ reflect.Type, fallbackTag string) *fields { name: tag.Name, index: f.Index, omitEmpty: omitEmpty || tag.HasOption("omitempty"), + omitZero: tag.HasOption("omitzero"), } if tag.HasOption("intern") { @@ -319,6 +332,29 @@ type isZeroer interface { IsZero() bool } +// isZeroValue reports whether v is the zero value for its type, using the same +// rule as encoding/json's `omitzero` (Go 1.24+): a type's own IsZero method +// takes precedence; otherwise the value is compared against the zero value via +// reflection. This is intentionally distinct from isEmptyValue (omitempty), +// which also omits non-nil empty slices, maps, and strings. +func isZeroValue(v reflect.Value) bool { + // A nil pointer or interface is the zero value; guard before invoking + // IsZero so a typed-nil (e.g. (*time.Time)(nil)) whose type carries an + // IsZero method does not get dereferenced. encoding/json guards this too. + if k := v.Kind(); (k == reflect.Pointer || k == reflect.Interface) && v.IsNil() { + return true + } + if z, ok := v.Interface().(isZeroer); ok { + return z.IsZero() + } + if v.CanAddr() { + if z, ok := v.Addr().Interface().(isZeroer); ok { + return z.IsZero() + } + } + return v.IsZero() +} + func (e *Encoder) isEmptyValue(v reflect.Value) bool { kind := v.Kind()