fix(schema): accept null for fields Firestore stores as null - #17
Merged
Conversation
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.
This was referenced Aug 23, 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.
The defect
Zod's
.optional()acceptsundefinedand rejectsnull. Firestore stores an absent optional field as an explicitnullunder 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:
Account.Schemanull;stockTicker,stockExchangenull in every documentPrice.Schemalimit(seat capacity) null in 11/12; alsodescription8/12,image4/12EventData.Schemablocks,users,media,imagesReproduced directly:
Price.safeParseon a document withlimit/image/sourcenull returned threeinvalid_typeissues, "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:parse/safeParsein this package documents its argument as the raw data of a stored document; every schema is az.looseObjectcomposed withbaseFirestoreShape(or is a mixin/array-element of one).dataargument or a webhook payload. Verified by enumerating every.parse/.safeParsecall 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 intoserialized-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 acceptsnullwhile the interface promises it cannot occur is exactly the runtime-versus-declaration mismatch this repo shipped before. The existingAssertSchemaOutputproofs forced every widening — the compiler named each drifting property.nullis preserved in the parse output rather than folded intoundefined. Folding would delete the stored field on a read-modify-write, andx === undefinedand'key' in objanswer differently for the two.Deliberately left as
.optional()— 9 fieldsThe instant-valued fields validated by
auditTimestamp()ortimestampLike():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
nullis the load-bearing absence the requirement exists to stop),Ledger.limit(already| null, andnullthere carries distinct meaning downstream), andIdempotency.Response.body.Nothing left unclassified
Verification
null. New tests assert that fields which now acceptnullstill 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 rejectnull..optional()→.nullish()turns it red.nullwhere the stored documents carrynull. A fixture usingundefinedthere parses identically under both spellings and asserts nothing — which is how this defect survived..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
Account.SchemaOutputwas documented as pinninglinksSchemato the upstreamUser.InterfaceLinksdefinition. It does not, and did not before this change: az.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.LinksKeysCoverednow compares keys — confirmed red on both an added and a renamed upstream member, green on the unchanged upstream.geohash,latitude,longitudeandplaceId, whichAccount.Interfacedoes 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
AccountandPricehave demonstrated production exposure.Postshowed 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,MessageUsageandReservationhave no legacy data but are actively consumed, and once null-writing starts the schema must already accept it.Idempotencyin particular is already written with explicitly null fields by merged consumer code.Breaking change
Widening a published type from
T | undefinedtoT | null | undefinedis breaking for readers compiling understrictNullChecks. That is the intent: it surfaces at compile time exactly the code paths that today silently mishandle a real storednull. Refusing to widen would leave the declared type disagreeing with whatparsereturns.Gates
npm run buildgit status --porcelain -uallafter rebuild)npm test(964 passed, 18 files)npm run typechecknpm run typecheck:consumernpm run typecheck:consumer:controlnpm run lint.github/scripts/check-private-markers.shtypecheck:consumerpasses but is inert for this change: it runs withstrictNullChecksoff, wherenullis assignable everywhere, so it cannot see a nullability widening. Reported as green, not as evidence.No
Co-authored-by:trailer: 0 matches overorigin/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.