Skip to content

fix: Detect nullability in a record value union - #13

Merged
razor-x merged 2 commits into
mainfrom
claude/nextlove-pr-182-migration-e59nfm
Aug 18, 2026
Merged

fix: Detect nullability in a record value union#13
razor-x merged 2 commits into
mainfrom
claude/nextlove-pr-182-migration-e59nfm

Conversation

@razor-x

@razor-x razor-x commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

A record value type can express nullability two ways, and only one was detected:

z.record(z.string(), z.string().nullable())            // → nullable_string_record ✅
z.record(z.string(), z.union([z.string(), z.null()]))  // → string_record         ❌

unwrapZodSchema reports isNullable for a ZodNullable wrapper, and flatPrimitiveValueType resolves a union by discarding its null option as a permissiveValueType. So a union's null option never reaches recordToValueType, the mixed union coerces to string, and the record resolves to string_record.

The consequence is in generous parsing: foo.a= returned '' for a schema that says the value may be null.

recordToValueType now also treats a value union containing ZodNull — or a union option that is itself nullable — as nullable. Scoped to the record path deliberately: flatPrimitiveValueType is shared with arrays, and there is no nullable_string_array for an array to promote to.

Invertibility is preserved, and generous parsing gets closer to strict

Strict parsing was already correct here, because parseLeaf maps an empty value to null for any string leaf when strict:

if (type === 'string' || type === 'nullable_string') {
  if (value.length > 0) return value
  return strict || type === 'nullable_string' ? null : ''
}

So this change cannot affect strict-mode invertibility — strict never produced '' in the first place. It only affects generous parsing, where it replaces '' with null for a nullable record, matching what the schema declares and what @seamapi/url-search-params-serializer emits: the serializer drops an empty-string value entirely and emits param= only for null, 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 to string_record and still yields '' in generous mode.

nullable_string_record had no test coverage

It 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 — five zodSchemaToParamSchema cases: the wrapper form, string | null, string | boolean | null, a union whose option is itself nullable, and a non-nullable union that must stay string_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 a string | boolean | null record 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 lint clean (eslint + prettier check).
  • npm run typecheck clean.
  • Confirmed the gap first by probing the published 0.3.0 behavior: 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: nextlove depends on @seamapi/url-search-params-parser@^0.3.0, so a patch reaches seam-connect through that caret with no nextlove release.

Downstream, this unblocks seam-connect's custom_metadata_has filter accepting null (seamapi/seam-connect#17182): an empty-string filter value has no query-string representation, so null has to be the canonical spelling of "unset", and that requires the parser to give null back.


Generated by Claude Code

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
Comment thread test/bijection.test.ts Outdated
@razor-x
razor-x merged commit 30ca897 into main Aug 18, 2026
11 checks passed
@razor-x
razor-x deleted the claude/nextlove-pr-182-migration-e59nfm branch August 18, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants