Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions testdata/TestOmitZero.golden
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
// @typecheck
export const AddressSchema = z.object({
City: z.string(),
})
export type Address = z.infer<typeof AddressSchema>

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<typeof PayloadSchema>

Original file line number Diff line number Diff line change
Expand Up @@ -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<TreeNode> = z.object(TreeNodeSchemaShape)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TreeNode> = z.object(TreeNodeSchemaShape)
Expand Down
150 changes: 146 additions & 4 deletions tests/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,7 @@ export const cases: TestCase[] = [
{
Value: "child",
CreatedAt: "2021-02-01T00:00:00Z",
DeletedAt: "2021-03-01T00:00:00Z",
Children: null,
},
],
Expand All @@ -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,
},
],
Expand Down Expand Up @@ -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,
},

Expand Down
37 changes: 26 additions & 11 deletions zod.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions zod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
}
Expand Down Expand Up @@ -1356,6 +1378,7 @@ func TestRecursiveEmbeddedWithPointersAndDates(t *testing.T) {
type TreeNode struct {
Value string
CreatedAt time.Time
DeletedAt time.Time `json:",omitzero"`
Children *[]TreeNode
}

Expand Down
Loading