fix: Detect nullability in a record value union - #13
Merged
Conversation
A record value type can express nullability two ways: as a wrapper, z.string().nullable(), or as a null option of a union, z.union([z.string(), z.null()]). Only the wrapper was detected, because unwrapZodSchema reports isNullable for a ZodNullable wrapper and flatPrimitiveValueType resolves a union by discarding its null option as permissive. The union form therefore resolved to string_record, and generous parsing returned "" for an empty value where the schema says null. Strict parsing was already correct — it maps an empty value to null for any string leaf — so this aligns generous parsing with strict and with the serializer, which drops an empty-string value and emits `param=` only for null. nullable_string_record was reachable but untested; it now has coverage for both spellings, in the schema, generous-parsing, bijection and zod-v4 suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqSKuMwLDKui1P6fDYA8xc
razor-x
commented
Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A record value type can express nullability two ways, and only one was detected:
unwrapZodSchemareportsisNullablefor aZodNullablewrapper, andflatPrimitiveValueTyperesolves a union by discarding itsnulloption as apermissiveValueType. So a union's null option never reachesrecordToValueType, the mixed union coerces tostring, and the record resolves tostring_record.The consequence is in generous parsing:
foo.a=returned''for a schema that says the value may be null.recordToValueTypenow also treats a value union containingZodNull— or a union option that is itself nullable — as nullable. Scoped to the record path deliberately:flatPrimitiveValueTypeis shared with arrays, and there is nonullable_string_arrayfor an array to promote to.Invertibility is preserved, and generous parsing gets closer to strict
Strict parsing was already correct here, because
parseLeafmaps an empty value tonullfor any string leaf when strict:So this change cannot affect strict-mode invertibility — strict never produced
''in the first place. It only affects generous parsing, where it replaces''withnullfor a nullable record, matching what the schema declares and what@seamapi/url-search-params-serializeremits: the serializer drops an empty-string value entirely and emitsparam=only fornull, at the top level and inside a nested object alike.A genuinely non-nullable record is unchanged —
z.union([z.string(), z.boolean()])still resolves tostring_recordand still yields''in generous mode.nullable_string_recordhad no test coverageIt was reachable through the wrapper spelling but nothing exercised it, in any suite — which is why the union gap went unnoticed. Coverage added for both spellings across the four suites that own this behavior:
src/lib/schema.test.ts— fivezodSchemaToParamSchemacases: the wrapper form,string | null,string | boolean | null, a union whose option is itself nullable, and a non-nullable union that must staystring_record.test/generous-parsing.test.ts— extends the existing "parses empty record value params by nullability" test with the union forms, including the non-nullable case that must still yield''.test/bijection.test.ts—{ foo: { a: 'x', b: null } }through the serializer and back with astring | boolean | nullrecord value.test/zod-v4.test.ts— the same shape built with Zod v4, since this code reads zod internals and that suite exists for exactly that reason.Testing
npx ava— 187 tests pass (was 181).npm run lintclean (eslint + prettier check).npm run typecheckclean.0.3.0behavior: wrapper →nullable_string_record, all three union forms →string_record.Release
No version bump in this PR, matching the history here (a
feat:/fix:commit, then a separate version commit). This is a patch —0.3.1— which matters downstream:nextlovedepends on@seamapi/url-search-params-parser@^0.3.0, so a patch reachesseam-connectthrough that caret with nonextloverelease.Downstream, this unblocks
seam-connect'scustom_metadata_hasfilter acceptingnull(seamapi/seam-connect#17182): an empty-string filter value has no query-string representation, sonullhas to be the canonical spelling of "unset", and that requires the parser to givenullback.Generated by Claude Code