Skip to content

Enable strictNullChecks and noImplicitAny, and lint the test tree - #12

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

Enable strictNullChecks and noImplicitAny, and lint the test tree#12
ernysans merged 1 commit into
mainfrom
ernysans-core-node-security-foundation

Conversation

@ernysans

Copy link
Copy Markdown
Member

Summary

tsconfig.json declared "strict": true and then switched two of its members back off, so the strictness the config appeared to promise was not the one in effect. Both are now on, and ESLint covers test/**/*.ts alongside src/**/*.ts.

Every emitted .js in the build output is unchanged. There is exactly one type-level difference in the entire lib/, described below.

Measured yield

Against the merged tree, before any fix:

Config src/ errors test/ errors
baseline (as merged) 0 0
strictNullChecks: true 0 0
noImplicitAny: true 0 7
both true 0 5

The non-monotonicity is the interesting part and it drove the packaging. noImplicitAny alone reports two errors that both flags together do not: without strictNullChecks, a null literal infers as any, so {body: null} trips TS7018. With both on, null infers as null and the error is correctly absent. The half-configuration is worse than either end, which is why this lands as one change rather than the three staged PRs originally planned.

The 5 real errors, and how they were resolved

All five are the same shape — a test reading a deliberately undeclared key to assert that the parse boundary preserves unknown keys rather than dropping them:

const parsed = parseMessageQueue({ pending: 1, legacyCounter: 9 });
expect(parsed['legacyCounter']).toBe(9);   // TS7053 under noImplicitAny

Resolved by making the read explicit — as unknown as Record<string, unknown>not by adding an index signature, casting to any, or disabling the rule. Each of those would have reproduced the exact permissiveness the flags exist to remove, while looking like the task was done.

The compiler rejected the single-step as Record<string, unknown> on two of the five (TS2352, "neither type sufficiently overlaps") and named the unknown-first remedy. That form is applied uniformly to all five so identical intent reads identically.

Mutation-tested, because a rewritten test is exactly where a vacuous one gets introduced: switching the parse boundary from z.looseObject to z.object (silently strips unknown keys) turns all five red — 18 failures, 15 files. Reverted, back to 903 passing.

One published type changes, and it is a correction

- body: z.ZodCustom<string, string>;
+ body: z.ZodCustom<string | null, string | null>;

The Idempotency response body is requiredKey(z.string().nullable(), …). Under the old setting the string | null union collapsed, so the schema's inferred output type claimed string — while the runtime accepts and preserves null, and the hand-written Interface beside it correctly declares string | null.

So the schema's inferred type contradicted both the runtime and the interface. It now reads string | null and all three agree. Verified at runtime with a positive control:

baseline valid record parses   : true      <- control; without this the probe proves nothing
null body accepted at runtime  : true  value=null

Consumers using the hand-written Interface were always correct and are unaffected. Anyone deriving from the schema was being told a value the runtime produces cannot occur.

Documentation corrected in the same commit

A change owns what it makes stale. Six places described the old configuration as current, including the rationale for the requiredKey wrapper — which existed solely to work around the inference collapse.

That collapse is now gone, verified before/after: z.object({u: z.union([…])}) accepted {} under the old setting (0 errors) and correctly reports TS2741 under the new one.

The wrapper is retained, not removed. It has two call sites, and removing it changes the errors they report. That deserves its own change with its own tests rather than arriving as a side effect of a compiler-flag change. Its documentation now states it is obsolete for its original purpose and should not be used for new code.

Verification

npm run build       0        lib/ drift       0 files (after commit)
npm test            0        903 passed, 18 files
npm run typecheck   0
npm run lint        0        38 files linted (20 src + 18 test)
private markers     0

Positive controls, since a green gate from an untested probe is not evidence:

  • planted const v: string | null = null; return v.lengthTS18047, as required
  • ESLint reports 18 test files linted (not the inert zero), and a planted syntax error in a test file exits non-zero
  • the lib/ comparison itself was controlled by injecting a declaration and confirming detection

No attribution trailers: 0 in commit messages (grep positive-controlled, returns 1 on a synthetic), single identity for both author and committer.

Note for the memberOf follow-up

Written under the stricter compiler, as intended. requiredKey should not be used for it — see its updated documentation.

tsconfig.json declared "strict": true and then switched two of its members
back off, so the strictness the config appeared to promise was not the one
in effect. Both are now on, and eslint covers test/**/*.ts alongside
src/**/*.ts, with parserOptions.project listing both tsconfigs so
type-aware rules resolve there.

Yield: 0 errors in src/, 5 in test/ -- all the same shape, a test reading
a deliberately undeclared key to assert that the parse boundary preserves
unknown keys. Each is resolved by making the read explicit
(`as unknown as Record<string, unknown>`) rather than by weakening a type
or silencing the check. The compiler rejected the single-step cast on two
of them and named the remedy; that form is used uniformly so identical
intent reads identically. Mutation-tested: switching the boundary from
z.looseObject to z.object turns all five red, so they still assert what
they claim.

Enabling noImplicitAny alone reports two errors that both flags together
do not: without strictNullChecks a `null` literal infers as `any`. The
half-configuration is worse than either end, which is why these land as
one change rather than staged.

One consequence worth calling out, since it changes a published type. The
inferred output of the Idempotency response body was `string`, while the
runtime schema accepts and preserves `null` and the hand-written interface
declares `string | null`. Under the old setting the union collapsed, so
the schema's inferred type contradicted both the runtime and the
interface beside it; it now reads `string | null` and the three agree.
Every emitted .js is unchanged, and that is the only type-level
difference in the entire build output.

Documentation that described the old configuration is corrected in the
same commit, including the rationale for the requiredKey wrapper, which
existed only to work around the inference collapse. The wrapper is
retained rather than removed here: it has two call sites and removing it
changes the errors they report, which deserves its own change and tests
rather than arriving as a side effect of a compiler flag.
@ernysans
ernysans merged commit 858cb70 into main Aug 22, 2026
2 checks passed
@ernysans
ernysans deleted the ernysans-core-node-security-foundation branch August 22, 2026 23:24
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