diff --git a/src/lib/schema.test.ts b/src/lib/schema.test.ts index bed39c1..3632e38 100644 --- a/src/lib/schema.test.ts +++ b/src/lib/schema.test.ts @@ -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( diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 3fe0273..af24b2e 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -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. @@ -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[], diff --git a/test/bijection.test.ts b/test/bijection.test.ts index c03a047..22f8d03 100644 --- a/test/bijection.test.ts +++ b/test/bijection.test.ts @@ -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.null()])), + }), +) + test( 'enum and literal params', bijection, diff --git a/test/generous-parsing.test.ts b/test/generous-parsing.test.ts index a88ebc1..d6f1651 100644 --- a/test/generous-parsing.test.ts +++ b/test/generous-parsing.test.ts @@ -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) => { diff --git a/test/zod-v4.test.ts b/test/zod-v4.test.ts index 03c0ae6..09722eb 100644 --- a/test/zod-v4.test.ts +++ b/test/zod-v4.test.ts @@ -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() }),