Skip to content

fix(schema): accept null for fields Firestore stores as null - #17

Merged
ernysans merged 1 commit into
mainfrom
ernysans-nullish-stored-document-fields
Aug 23, 2026
Merged

fix(schema): accept null for fields Firestore stores as null#17
ernysans merged 1 commit into
mainfrom
ernysans-nullish-stored-document-fields

Conversation

@ernysans

Copy link
Copy Markdown
Member

The defect

Zod's .optional() accepts undefined and rejects null. Firestore stores an absent optional field as an explicit null under common write patterns. A document written normally therefore failed a schema that declared that field .optional() — the schemas rejected the documents they exist to validate.

Measured read-only against stored data before the change:

shape sampled rejected notes
Account.Schema 25 25/25 31 distinct fields stored null; stockTicker, stockExchange null in every document
Price.Schema 12 12/12 limit (seat capacity) null in 11/12; also description 8/12, image 4/12
EventData.Schema 3 1/3 12 distinct fields null, including the array fields blocks, users, media, images

Reproduced directly: Price.safeParse on a document with limit/image/source null returned three invalid_type issues, "expected number, received null".

Classification — the actual job

Every .optional() site was classified as stored-document or inbound-payload. The result is one-sided, and that was checked rather than assumed:

  • Stored-document — all 16 exported shapes. Every parse/safeParse in this package documents its argument as the raw data of a stored document; every schema is a z.looseObject composed with baseFirestoreShape (or is a mixin/array-element of one).
  • Inbound payload — none. No schema here validates an HTTP body, a callable data argument or a webhook payload. Verified by enumerating every .parse/.safeParse call site in the consuming service, including aliased imports, re-exports and wrapped schemas (.extend/.omit/.partial/.shape). All call sites read stored Firestore data. The search carried a positive control: the same pattern returned a true positive on a known call site and returned empty for namespaces that genuinely have none.

An inbound schema would correctly keep .optional(), because a JSON body omits a key rather than nulling it, and .nullish() there would weaken untrusted-input validation. That rule is now written into serialized-models.instructions.md, together with a requirement to declare a separate schema rather than reuse a stored-document one.

What changed

193 schema fields → .nullish(), and 164 interface properties → | null. Those are one change, not two: a schema that accepts null while the interface promises it cannot occur is exactly the runtime-versus-declaration mismatch this repo shipped before. The existing AssertSchemaOutput proofs forced every widening — the compiler named each drifting property.

null is preserved in the parse output rather than folded into undefined. Folding would delete the stored field on a read-modify-write, and x === undefined and 'key' in obj answer differently for the two.

Deliberately left as .optional() — 9 fields

The instant-valued fields validated by auditTimestamp() or timestampLike(): created, updated, expiry (base), Account.domainTimestamp, EventData.startTime/endTime, Idempotency.lockExpires, MessageUsage.firstAttemptedAt, Capacity.expires.

An explicitly null timestamp is not a time; read as one it becomes epoch zero, which sorts first and — for a TTL — expires the document immediately. No stored null was observed in any of these nine across ~90 sampled documents, so the exemption costs nothing today. It is inventoried in the tests, so it cannot be widened silently.

Also unchanged: required fields (a required field carrying null is the load-bearing absence the requirement exists to stop), Ledger.limit (already | null, and null there carries distinct meaning downstream), and Idempotency.Response.body.

Nothing left unclassified

Verification

  • The loosening is bounded to null. New tests assert that fields which now accept null still reject wrong types, fractional and negative counters, unrecognised enum members, out-of-range values and malformed array elements — and that required fields and audit timestamps still reject null.
  • The policy inventory is now a reject-list, not an accept-list. An accept-list grows by one entry per loosening and would never catch a blanket sweep; a reject-list shrinks toward empty, so a future blanket .optional().nullish() turns it red.
  • Fixtures carry null where the stored documents carry null. A fixture using undefined there parses identically under both spellings and asserts nothing — which is how this defect survived.
  • 12 mutations, 12 red. Reverting a fixed field to .optional() (7 cases), loosening a deliberately-kept timestamp (3), and loosening a required field (2). Every one turned the suite red; reverted after each.

Two findings surfaced along the way

  1. A pre-existing compile-time claim was false. Account.SchemaOutput was documented as pinning linksSchema to the upstream User.InterfaceLinks definition. It does not, and did not before this change: a z.looseObject's inferred index signature does not supply named members to satisfy an optional target property, so a member added upstream checked clean. Measured by adding one and watching the build stay green. Account.LinksKeysCovered now compares keys — confirmed red on both an added and a renamed upstream member, green on the unchanged upstream.
  2. Stored accounts carry geohash, latitude, longitude and placeId, which Account.Interface does not declare. These pass through as unknown keys and never caused a rejection, so they are not part of this defect. Whether to model them is left to the owner of that shape and noted in the source.

Scope honesty

Account and Price have demonstrated production exposure. Post showed 0 nulls in a 20-document sample — that is a sample, not a census, and it is converted on the uniform rationale rather than on measured exposure, since the defect is a property of the storage layer rather than of any one collection. Capacity, Entitlement, Idempotency, Ledger, MessageUsage and Reservation have no legacy data but are actively consumed, and once null-writing starts the schema must already accept it. Idempotency in particular is already written with explicitly null fields by merged consumer code.

Breaking change

Widening a published type from T | undefined to T | null | undefined is breaking for readers compiling under strictNullChecks. That is the intent: it surfaces at compile time exactly the code paths that today silently mishandle a real stored null. Refusing to widen would leave the declared type disagreeing with what parse returns.

Gates

gate exit
npm run build 0
build-output drift (git status --porcelain -uall after rebuild) clean
npm test (964 passed, 18 files) 0
npm run typecheck 0
npm run typecheck:consumer 0
npm run typecheck:consumer:control 0
npm run lint 0
.github/scripts/check-private-markers.sh 0

typecheck:consumer passes but is inert for this change: it runs with strictNullChecks off, where null is assignable everywhere, so it cannot see a nullability widening. Reported as green, not as evidence.

No Co-authored-by: trailer: 0 matches over origin/main..HEAD, with the grep positive-controlled against text that genuinely contains one, and authorship confirmed to be a single identity matching the committer so a squash merge cannot generate one server-side.

Zod's `.optional()` accepts `undefined` and rejects `null`. Firestore stores
an absent optional field as an explicit `null` under common write patterns, so
a document written normally failed a schema that declared that field
`.optional()`. The schemas rejected the documents they exist to validate.

Measured before the change, read-only against stored data: 25 of 25 sampled
account documents and 12 of 12 sampled price documents were rejected. On the
price documents `limit` — seat capacity — was null in 11 of 12.

Every optional field on a stored-document schema is now `.nullish()`, and the
matching interface property is annotated `| null`. Those are one change: a
schema that accepts null while the interface promises it cannot occur is a
runtime-versus-declaration mismatch that no type checking can see. 193 schema
fields and 164 interface properties, across every model and the base, place and
queue mixins.

`null` is preserved in the parse output rather than folded into `undefined`.
Folding would delete the stored field on a read-modify-write, and `x ===
undefined` and `'key' in obj` answer differently for the two.

The loosening is bounded to null and nothing else. Required fields still reject
null, because a required field carrying null is the load-bearing absence the
requirement exists to stop. The nine instant-valued fields validated by
`auditTimestamp()` or `timestampLike()` also still reject it and stay
`.optional()`: an explicitly null timestamp is not a time, and read as one it
becomes epoch zero, which sorts first and — for a TTL — expires the document
immediately. No stored null was observed in any of those nine.

No schema in this package validates an inbound payload; every parse boundary
here reads a stored document. That was checked rather than assumed, with a
positive control on the search. An inbound schema would keep `.optional()`,
because a JSON body omits a key rather than nulling it, and must be declared
separately rather than reusing one of these.

The policy inventory in test/interface/schema.test.ts is now a reject-list
rather than an accept-list, so it shrinks toward empty and a later blanket
loosening turns it red; an accept-list would have grown silently instead.
Stored-layout regression fixtures for Account, Price and EventData carry null
in the same fields the stored documents do, because a fixture using `undefined`
there parses identically under both spellings and asserts nothing. Twelve
mutations were run against them — reverting a fixed field, loosening a kept
timestamp, loosening a required field — and all twelve turned red.

Separately, the claim that Account's SchemaOutput pinned `linksSchema` to the
upstream links definition was false, and was false before this change: a
`z.looseObject`'s inferred index signature does not supply named members to
satisfy an optional target property, so a member added upstream checked clean.
Measured by adding one and watching the build stay green. `LinksKeysCovered`
now compares keys and fails on an added or renamed member; both cases were
confirmed red, with the unchanged upstream confirmed green.

Stored accounts also carry geohash, latitude, longitude and placeId, which the
account interface does not declare. They pass through as unknown keys and never
caused a rejection; whether to model them is left to the owner of that shape.
@ernysans
ernysans merged commit c08006f into main Aug 23, 2026
2 checks passed
@ernysans
ernysans deleted the ernysans-nullish-stored-document-fields branch August 23, 2026 12: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.

1 participant