diff --git a/testdata/TestOmitZero.golden b/testdata/TestOmitZero.golden index 602cd0f..486f874 100644 --- a/testdata/TestOmitZero.golden +++ b/testdata/TestOmitZero.golden @@ -1,8 +1,29 @@ // @typecheck +export const AddressSchema = z.object({ + City: z.string(), +}) +export type Address = z.infer + export const PayloadSchema = z.object({ Name: z.string(), Nickname: z.string().optional(), Email: z.string().optional(), + Address: AddressSchema.optional(), + AddressOmitEmpty: AddressSchema, + Inline: z.object({ + X: z.number(), + }).optional(), + CreatedAt: z.coerce.date().optional(), + CreatedAtEmpty: z.coerce.date(), + RequiredAt: z.coerce.date().refine((val) => val.getTime() !== new Date('0001-01-01T00:00:00Z').getTime() && val.getTime() !== new Date(0).getTime(), 'Invalid date'), + DeletedAt: z.coerce.date().optional(), + Pair: z.number().array().length(2).optional(), + Tags: z.string().array().optional(), + Labels: z.record(z.string(), z.string()).optional(), + TagsNullable: z.string().array().optional().nullable(), + LabelsNullable: z.record(z.string(), z.string()).optional().nullable(), + omitzero: z.string(), + omitempty: z.string(), }) export type Payload = z.infer diff --git a/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v3.golden b/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v3.golden index fccf80b..2dbb63b 100644 --- a/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v3.golden +++ b/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v3.golden @@ -3,11 +3,13 @@ export type TreeNode = { Value: string, CreatedAt: Date, + DeletedAt?: Date | undefined, Children: TreeNode[] | null, } const TreeNodeSchemaShape = { Value: z.string(), CreatedAt: z.coerce.date(), + DeletedAt: z.coerce.date().optional(), Children: z.lazy(() => TreeNodeSchema).array().nullable(), } export const TreeNodeSchema: z.ZodType = z.object(TreeNodeSchemaShape) diff --git a/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v4.golden b/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v4.golden index 06a670d..c638561 100644 --- a/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v4.golden +++ b/testdata/TestRecursiveEmbeddedWithPointersAndDates/recursive_struct_with_pointer_field_and_date/v4.golden @@ -3,11 +3,13 @@ export type TreeNode = { Value: string, CreatedAt: Date, + DeletedAt?: Date | undefined, Children: TreeNode[] | null, } const TreeNodeSchemaShape = { Value: z.string(), CreatedAt: z.coerce.date(), + DeletedAt: z.coerce.date().optional(), get Children() { return TreeNodeSchema.array().nullable(); }, } export const TreeNodeSchema: z.ZodType = z.object(TreeNodeSchemaShape) diff --git a/tests/cases.ts b/tests/cases.ts index f96b3a9..dfd8be8 100644 --- a/tests/cases.ts +++ b/tests/cases.ts @@ -841,6 +841,7 @@ export const cases: TestCase[] = [ { Value: "child", CreatedAt: "2021-02-01T00:00:00Z", + DeletedAt: "2021-03-01T00:00:00Z", Children: null, }, ], @@ -854,6 +855,7 @@ export const cases: TestCase[] = [ { Value: "child", CreatedAt: new Date("2021-02-01T00:00:00Z"), + DeletedAt: new Date("2021-03-01T00:00:00Z"), Children: null, }, ], @@ -1028,21 +1030,161 @@ export const cases: TestCase[] = [ name: "omitzero: accepts all fields present", golden: "TestOmitZero.golden", schema: "PayloadSchema", - input: { Name: "alice", Nickname: "ally", Email: "a@b.com" }, + input: { + Name: "alice", + Nickname: "ally", + Email: "a@b.com", + Address: { City: "Paris" }, + AddressOmitEmpty: { City: "" }, + Inline: { X: 1 }, + CreatedAt: "2021-01-01T00:00:00Z", + CreatedAtEmpty: "0001-01-01T00:00:00Z", + RequiredAt: "2021-01-01T00:00:00Z", + DeletedAt: "2021-01-01T00:00:00Z", + Pair: [0, 1], + Tags: [], + Labels: {}, + TagsNullable: null, + LabelsNullable: null, + omitzero: "", + omitempty: "", + }, success: true, + output: { + Name: "alice", + Nickname: "ally", + Email: "a@b.com", + Address: { City: "Paris" }, + AddressOmitEmpty: { City: "" }, + Inline: { X: 1 }, + CreatedAt: new Date("2021-01-01T00:00:00Z"), + CreatedAtEmpty: new Date("0001-01-01T00:00:00Z"), + RequiredAt: new Date("2021-01-01T00:00:00Z"), + DeletedAt: new Date("2021-01-01T00:00:00Z"), + Pair: [0, 1], + Tags: [], + Labels: {}, + TagsNullable: null, + LabelsNullable: null, + omitzero: "", + omitempty: "", + }, }, { - name: "omitzero: accepts omitzero fields missing", + name: "omitzero: accepts omitzero fields missing (including zero structs and times)", golden: "TestOmitZero.golden", schema: "PayloadSchema", - input: { Name: "alice" }, + input: { + Name: "alice", + AddressOmitEmpty: { City: "" }, + CreatedAtEmpty: "0001-01-01T00:00:00Z", + RequiredAt: "2021-01-01T00:00:00Z", + omitzero: "", + omitempty: "", + }, success: true, + output: { + Name: "alice", + AddressOmitEmpty: { City: "" }, + CreatedAtEmpty: new Date("0001-01-01T00:00:00Z"), + RequiredAt: new Date("2021-01-01T00:00:00Z"), + omitzero: "", + omitempty: "", + }, }, { name: "omitzero: rejects required field missing", golden: "TestOmitZero.golden", schema: "PayloadSchema", - input: { Nickname: "ally" }, + input: { + Nickname: "ally", + AddressOmitEmpty: { City: "" }, + CreatedAtEmpty: "0001-01-01T00:00:00Z", + RequiredAt: "2021-01-01T00:00:00Z", + omitzero: "", + omitempty: "", + }, + success: false, + }, + { + name: "omitzero: rejects omitempty struct missing (omitempty never omits structs)", + golden: "TestOmitZero.golden", + schema: "PayloadSchema", + input: { + Name: "alice", + CreatedAtEmpty: "0001-01-01T00:00:00Z", + RequiredAt: "2021-01-01T00:00:00Z", + omitzero: "", + omitempty: "", + }, + success: false, + }, + { + name: "omitzero: rejects omitempty time missing (omitempty never omits time.Time)", + golden: "TestOmitZero.golden", + schema: "PayloadSchema", + input: { + Name: "alice", + AddressOmitEmpty: { City: "" }, + RequiredAt: "2021-01-01T00:00:00Z", + omitzero: "", + omitempty: "", + }, + success: false, + }, + { + name: "omitzero: rejects omitzero time with validate required missing", + golden: "TestOmitZero.golden", + schema: "PayloadSchema", + input: { + Name: "alice", + AddressOmitEmpty: { City: "" }, + CreatedAtEmpty: "0001-01-01T00:00:00Z", + omitzero: "", + omitempty: "", + }, + success: false, + }, + { + name: "omitzero: rejects null for omitzero struct", + golden: "TestOmitZero.golden", + schema: "PayloadSchema", + input: { + Name: "alice", + Address: null, + AddressOmitEmpty: { City: "" }, + CreatedAtEmpty: "0001-01-01T00:00:00Z", + RequiredAt: "2021-01-01T00:00:00Z", + omitzero: "", + omitempty: "", + }, + success: false, + }, + { + name: "omitzero: rejects null for omitzero slice", + golden: "TestOmitZero.golden", + schema: "PayloadSchema", + input: { + Name: "alice", + AddressOmitEmpty: { City: "" }, + CreatedAtEmpty: "0001-01-01T00:00:00Z", + RequiredAt: "2021-01-01T00:00:00Z", + Tags: null, + omitzero: "", + omitempty: "", + }, + success: false, + }, + { + name: "omitzero: rejects missing fields named omitzero/omitempty", + golden: "TestOmitZero.golden", + schema: "PayloadSchema", + input: { + Name: "alice", + AddressOmitEmpty: { City: "" }, + CreatedAtEmpty: "0001-01-01T00:00:00Z", + RequiredAt: "2021-01-01T00:00:00Z", + }, success: false, }, diff --git a/zod.go b/zod.go index fe9dfa8..8dc9f8a 100644 --- a/zod.go +++ b/zod.go @@ -963,7 +963,7 @@ func getValidateAfterDive(validate string) string { func getValidateKeys(validate string) string { var validateKeys string if strings.Contains(validate, "keys") { - removedSuffix := strings.SplitN(validate, ",endkeys", 2)[0] + removedSuffix, _, _ := strings.Cut(validate, ",endkeys") parts := strings.SplitN(removedSuffix, "keys,", 2) if len(parts) == 2 { validateKeys = parts[1] @@ -1513,13 +1513,13 @@ func isNullable(field reflect.StructField) bool { return false } - jsonTag := field.Tag.Get("json") + omittable := hasJSONOption(field, "omitempty") || hasJSONOption(field, "omitzero") // pointers can be nil, which are mapped to null in JS/TS. if field.Type.Kind() == reflect.Pointer { // However, if a pointer field is tagged with "omitempty"/"omitzero", it usually cannot be exported // as "null" since nil is a pointer's empty/zero value. - if strings.Contains(jsonTag, "omitempty") || strings.Contains(jsonTag, "omitzero") { + if omittable { // Unless it is a pointer to a slice, a map, a pointer, or an interface // because values with those types can themselves be nil and will be exported as "null". k := field.Type.Elem().Kind() @@ -1532,7 +1532,7 @@ func isNullable(field reflect.StructField) bool { // nil slices and maps are exported as null so these types are usually nullable if field.Type.Kind() == reflect.Slice || field.Type.Kind() == reflect.Map { // unless there are also optional in which case they are no longer nullable - return !strings.Contains(jsonTag, "omitempty") && !strings.Contains(jsonTag, "omitzero") + return !omittable } return false @@ -1569,12 +1569,8 @@ func isFieldIgnored(field reflect.StructField) bool { func isOptional(field reflect.StructField) bool { validateCurrent := getValidateCurrent(field.Tag.Get("validate")) - // Non-pointer struct types and direct or indirect interface types should never be optional(). - // Struct fields that are themselves structs ignore the "omitempty" tag because - // structs do not have an empty value. // Interfaces are currently exported with "any" type, which already includes "undefined" - if field.Type.Kind() == reflect.Struct || isInterface(field) || - strings.Contains(validateCurrent, "required") { + if isInterface(field) || strings.Contains(validateCurrent, "required") { return false } @@ -1584,9 +1580,28 @@ func isOptional(field reflect.StructField) bool { return false } + omitZero := hasJSONOption(field, "omitzero") + + // Structs have no empty value, so "omitempty" never omits them. "omitzero" does, + // when the struct is zero or its IsZero method reports true (e.g. time.Time). + if field.Type.Kind() == reflect.Struct { + return omitZero + } + // Otherwise, omitempty/omitzero zero-values are omitted and are mapped to undefined in JS/TS. - jsonTag := field.Tag.Get("json") - return strings.Contains(jsonTag, "omitempty") || strings.Contains(jsonTag, "omitzero") + return omitZero || hasJSONOption(field, "omitempty") +} + +// hasJSONOption reports whether the field's json tag carries the given option. +// The tag's first element is the field name, so `json:"omitzero"` has no options. +func hasJSONOption(field reflect.StructField, option string) bool { + _, options, _ := strings.Cut(field.Tag.Get("json"), ",") + for opt := range strings.SplitSeq(options, ",") { + if opt == option { + return true + } + } + return false } func indentation(level int) string { diff --git a/zod_test.go b/zod_test.go index 938f8db..33974c9 100644 --- a/zod_test.go +++ b/zod_test.go @@ -267,10 +267,32 @@ func TestStringOptionalNullable(t *testing.T) { } func TestOmitZero(t *testing.T) { + type Address struct { + City string + } type Payload struct { Name string Nickname string `json:",omitzero"` Email *string `json:",omitzero"` + + // encoding/json omits zero structs (or ones whose IsZero reports true) under + // omitzero, but never omits structs under omitempty. + Address Address `json:",omitzero"` + AddressOmitEmpty Address `json:",omitempty"` //nolint:modernize // intentional: contrasts with omitzero + Inline struct{ X int } `json:",omitzero"` + CreatedAt time.Time `json:",omitzero"` + CreatedAtEmpty time.Time `json:",omitempty"` //nolint:modernize // intentional: contrasts with omitzero + RequiredAt time.Time `json:",omitzero" validate:"required"` + DeletedAt *time.Time `json:",omitzero"` + Pair [2]int `json:",omitzero"` + Tags []string `json:",omitzero"` // empty slices are emitted as [], only nil is omitted + Labels map[string]string `json:",omitzero"` + TagsNullable *[]string `json:",omitzero"` // a non-nil pointer to a nil slice is emitted as null + LabelsNullable *map[string]string `json:",omitzero"` + + // These are field names, not options. + NamedOmitZero string `json:"omitzero"` + NamedOmitEmpty string `json:"omitempty"` } assertSchema(t, Payload{}) } @@ -1356,6 +1378,7 @@ func TestRecursiveEmbeddedWithPointersAndDates(t *testing.T) { type TreeNode struct { Value string CreatedAt time.Time + DeletedAt time.Time `json:",omitzero"` Children *[]TreeNode }