Skip to content

Declare rejection messages on the schemas and deprecate requiredKey - #14

Merged
ernysans merged 1 commit into
mainfrom
ernysans-core-node-security-foundation
Aug 22, 2026
Merged

Declare rejection messages on the schemas and deprecate requiredKey#14
ernysans merged 1 commit into
mainfrom
ernysans-core-node-security-foundation

Conversation

@ernysans

Copy link
Copy Markdown
Member

Summary

requiredKey existed solely to work around a type-inference collapse caused by strictNullChecks being off. That flag is now on, the collapse is gone, and both call sites are migrated.

memberOf was not built — checking main first showed #11 already merged matchMember/requireMember, so building it would have been duplication. This is the other half of that follow-up.

The wrapper is genuinely obsolete — measured

Probed by asserting an empty object against a schema with a required union key:

{} accepted?
old setting (strictNullChecks: false) yes — 0 errors, key wrongly inferred optional
now, with wrapper no — TS2739
now, without wrapper no — TS2739

Negative control: a genuinely optional key produces 0 errors, so the probe isn't always-failing.

(My first attempt at this probe used as never, which suppresses the exact check being tested and returned a meaningless 0. Redone.)

But deleting it would have been a regression — this is the point of the PR

The wrapper also carried the rejection message. Removing it and stopping there:

case with wrapper bare removal
Block, bad value Expected a string, number, object or array block value Invalid input
Block, missing value same descriptive message Invalid input
Idempotency, bad body Expected a response body string or null expected string, received number

Zod reports a failed union as Invalid input because it cannot know which member the caller intended. And expected string is true of the inner schema but false of the field, which accepts null — it sends a caller looking for the wrong fix.

That's the "cleanup" trap: the obvious follow-up (delete the now-obsolete helper) passes every gate while quietly degrading what a caller sees.

The fix keeps both properties

Zod accepts the message on the schema itself:

z.union([...], {error: 'Expected a string, number, object or array block value'})
z.string({error: 'Expected a response body string or null'}).nullable()

Identical messages, key still inferred required, and the z.custom indirection is gone.

Message quality is now tested

Three tests assert the exact strings, because this is precisely what a future cleanup discards silently. Each was mutation-tested by removing the error parameter it guards:

  • drop the union's error → 2 red (should name the accepted shapes …)
  • drop the nullable's error → 1 red (should name null as accepted …)
  • reverted → 922 passing

requiredKey is deprecated, not removed

It is a published export, and the inventory test asserting the public surface still lists it. Removing it would be a breaking change for any consumer that imported it. It is marked @deprecated naming the replacement, with the reasoning inline so the next reader doesn't re-derive it.

Consumer impact: none

parse, safeParse and Interface signatures are unchanged across all three affected files.

The Schema objects' own declared types change from ZodCustom<…> to ZodUnion<…> / ZodNullable<…> — which is what those schemas now are. The inferred output is identical, confirmed by compiling a real consumer against the built package under strict:

consumer compiles           errors = 0     (string | number | Record | unknown[] all accepted; body null accepted)
negative control (boolean)  TS2322: Type 'boolean' is not assignable to
                            type 'string | number | Record<string, unknown> | unknown[]'

The control both proves the probe works and shows the inferred union is unchanged.

(EventData.d.ts also shifts, since it embeds the block schema. Same character.)

Verification

npm run build       0        lib/ drift    0 files
npm test            0        922 passed, 18 files
npm run typecheck   0
npm run lint        0
private markers     0

Every .d.ts and .js in lib/ was diffed against main with comments stripped, and the comparison was itself controlled by injecting a declaration and confirming detection. No attribution trailers; single identity for author and committer.

The requiredKey wrapper existed to work around a type-inference collapse
caused by strictNullChecks being off: zod carries a schema's optionality
marker as a union with undefined, and with that flag off the union
collapsed, so a required key whose schema was a union or a nullable was
inferred as optional. The flag is now on and the collapse is gone --
verified by inferring an empty object against a schema with a required
union key and observing it is now correctly rejected, where it previously
type-checked clean.

Simply deleting the wrapper would have been a regression, which is the
part worth recording. It carried the rejection message, and without it
zod reports a failed union as `Invalid input` -- it cannot know which
member the caller intended -- and reports a failed string inside a
nullable as `expected string`, which is true of the inner schema but
false of the field. Both send a caller looking for the wrong fix.

Zod accepts the message on the schema itself, which keeps the wording,
keeps the key inferred as required, and drops the z.custom indirection.
Both call sites now use that form.

Three tests are added asserting the exact messages, because message
quality is the thing a future cleanup would silently discard. Each was
mutation-tested by removing the error parameter it guards: two turn red
for the union and one for the nullable body.

requiredKey is retained and marked deprecated rather than removed. It is
a published export, so dropping it would break any consumer that imported
it, and the inventory test asserting the public surface still lists it.

The emitted parse, safeParse and Interface signatures are unchanged. The
Schema objects' own declared types change from ZodCustom to ZodUnion and
ZodNullable, which is what those schemas now are; the inferred output is
identical, confirmed by compiling a consumer against the built package
under strict mode, including that a null response body is still accepted.
@ernysans
ernysans merged commit 055fe89 into main Aug 22, 2026
2 checks passed
@ernysans
ernysans deleted the ernysans-core-node-security-foundation branch August 22, 2026 23:49
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