Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
30 changes: 30 additions & 0 deletions src/lib/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,36 @@ test(
z.record(z.string(), z.union([z.string(), z.number()])),
'string_record',
)
test(
'nullable string value records',
valueType,
z.record(z.string(), z.string().nullable()),
'nullable_string_record',
)
test(
'null union value records',
valueType,
z.record(z.string(), z.union([z.string(), z.null()])),
'nullable_string_record',
)
test(
'mixed null union value records',
valueType,
z.record(z.string(), z.union([z.string(), z.boolean(), z.null()])),
'nullable_string_record',
)
test(
'nullable option union value records',
valueType,
z.record(z.string(), z.union([z.string().nullable(), z.boolean()])),
'nullable_string_record',
)
test(
'non-null union value records',
valueType,
z.record(z.string(), z.union([z.string(), z.boolean()])),
'string_record',
)

test('nullable object schemas', (t) => {
t.deepEqual(
Expand Down
17 changes: 16 additions & 1 deletion src/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,9 @@ const recordToValueType = (schema: ZodTypeAny, path: string[]): ValueType => {
const valueType = flatPrimitiveValueType(value, path, 'a record', true)

const nullableValueType =
isNullable && valueType === 'string' ? 'nullable_string' : valueType
(isNullable || hasNullUnionOption(value)) && valueType === 'string'
? 'nullable_string'
: valueType

// A record of only null values carries no primitive type information,
// so parse the values as strings.
Expand All @@ -311,6 +313,19 @@ const recordToValueType = (schema: ZodTypeAny, path: string[]): ValueType => {
return recordValueType
}

// A record value schema can express nullability as a wrapper,
// z.string().nullable(), or as a null option of a union,
// z.union([z.string(), z.null()]). flatPrimitiveValueType resolves a union by
// discarding its null option as permissive, so the union form is detected here.
const hasNullUnionOption = (schema: ZodTypeAny): boolean => {
if (!isZodUnion(schema) && !isZodDiscriminatedUnion(schema)) return false

return zodUnionOptions(schema).some((option) => {
const { schema: inner, isNullable } = unwrapZodSchema(option)
return isNullable || isZodNull(inner)
})
}

const assertNotNested = (
schema: ZodTypeAny,
path: string[],
Expand Down
11 changes: 11 additions & 0 deletions test/bijection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,17 @@ test(
}),
)

test(
'null union record value params',
bijection,
{
foo: { a: 'x', b: null },
},
z.object({
foo: z.record(z.string(), z.union([z.string(), z.boolean(), z.null()])),
Comment thread
razor-x marked this conversation as resolved.
Outdated
}),
)

test(
'enum and literal params',
bijection,
Expand Down
27 changes: 27 additions & 0 deletions test/generous-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,33 @@ test('parses empty record value params by nullability', (t) => {
),
{ foo: { a: null } },
)
t.deepEqual(
parse(
'foo.a=',
z.object({
foo: z.record(z.string(), z.union([z.string(), z.null()])),
}),
),
{ foo: { a: null } },
)
t.deepEqual(
parse(
'foo.a=',
z.object({
foo: z.record(z.string(), z.union([z.string(), z.boolean(), z.null()])),
}),
),
{ foo: { a: null } },
)
t.deepEqual(
parse(
'foo.a=',
z.object({
foo: z.record(z.string(), z.union([z.string(), z.boolean()])),
}),
),
{ foo: { a: '' } },
)
})

test('parses additional strings as true', (t) => {
Expand Down
9 changes: 9 additions & 0 deletions test/zod-v4.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ test('zod-v4: parses records', (t) => {
t.deepEqual(parse('foo.a=1&foo.b=2', schema), { foo: { a: 1, b: 2 } })
})

test('zod-v4: parses records with a null union value type', (t) => {
const schema = z.object({
foo: z.record(z.string(), z.union([z.string(), z.boolean(), z.null()])),
})
t.deepEqual(parse('foo.a=x&foo.b=', schema, { strict: false }), {
foo: { a: 'x', b: null },
})
})

test('zod-v4: parses unions of objects', (t) => {
const schema = z.union([
z.object({ a: z.string() }),
Expand Down
Loading