test: compile published declarations under consumer conditions - #15
Merged
Conversation
This package compiles with strict, strictNullChecks and noImplicitAny all on. Consumers need not, and the same declaration can enforce something here and enforce nothing for them. A failure branch marked `data?: undefined` errors correctly under strictNullChecks and collapses without it, because `T | undefined` reduces to `T` when the flag is off — the unguarded read then compiles clean and throws at runtime. The current types are already the right shape: ParseFailure and MemberMiss omit the property entirely, so the guarantee rests on property existence and fires either way. But nothing enforced that shape. Every existing gate runs under this repository's own settings, so all of them are structurally incapable of telling the two forms apart. The unspoken precondition — that a consumer shares our null-checking setting — was true until recently, is now false, and was tracked by nothing. Adds a third gate that does not overlap the other two: - tsconfig.consumer.json compiles test-consumer/ against the BUILT lib/*.d.ts, reached by self-name import through the package's own exports map, with strictNullChecks and noImplicitAny off. Verified with --listFiles that only lib/interface/*.d.ts and the fixture compile; no file from src/ is read. It deliberately does not extend tsconfig.json, because a consumer inherits nothing from it. - Negative cases use @ts-expect-error with descriptions, so they are self-proving: if a guarantee breaks the expected error stops occurring, the directive goes unused, and tsc fails with TS2578. Each is paired with a narrowed positive so a merely unusable type cannot satisfy it. - Inert same-shape controls carry no directive and must compile clean. They prove the settings really are permissive enough to miss the marker form, and they pin the config: forcing strictNullChecks on errors on exactly those two lines and nothing else. Also corrects a claim in the ParseFailure docs that this package compiles with strictNullChecks off, which stopped being true, and records a measured consumer-visible caveat: where strictNullChecks is off, negative narrowing of a boolean discriminant does not fire at all, so `r.ok ? a : r.err` and the else of `if (r.ok)` leave the value un-narrowed. `=== false`, `=== true` and `in` narrow under both settings. eslint.config.js now covers test-consumer/. Measured before the change: the directory was linted only by the default rule set, not this repository's own block — a planted 255-character line reported no-unused-vars (a rule this repo turns off) and not max-len (a rule it turns on). After: max-len is reported. Adding tsconfig.consumer.json to parserOptions.project is load-bearing; without it the fixture is a parsing error.
…low read The one-cell experiment gives the wrong answer, and I had written it up as a success before running it. Reintroducing `data?: undefined` on the failure branch turns BOTH gates red, not only the new one: the existing assertions in test/interface/schema.test.ts read the shallow property `result.data`, and `Property 'data' does not exist` fires under every null-checking setting. The divergence is real but needs a 2x2 to see. Rewriting those reads as `result.data.amount` — the natural way to express the guarantee, and what it is actually about — leaves `npm run typecheck` green under the marker, because TS18048 keeps its directive used, while `npm run typecheck:consumer` reports TS2578. Control cell, same deep reads with the correct type, leaves both green, ruling out a simply-broken test. The identical 2x2 on `member?: undefined` and MemberMiss behaves the same and fails the fixture's other two directives, so all four have now been observed failing under the mutation they exist to catch. So the strict gate's coverage of this class is incidental to how one line was phrased; the consumer gate's is structural. That makes the shallow phrasing in test/interface/schema.test.ts load-bearing rather than lazy, and it looks exactly like something a reader would tidy up into a deep access. Documented in place, with the measurement, so the "improvement" is visibly a regression. Also drops a stale sentence there claiming this repository compiles with strictNullChecks off. Corrects the same overstated claim in the CI step comment and in tests.instructions.md, which now carries the 2x2 as a table.
Two corrections to the gate as first written. 1. The differential was not controlled. "Un-narrowed read failed to compile" is produced just as readily by a fixture that cannot compile at all, and both routes are live here, measured: TS5112, where passing files on the command line means tsconfig is never loaded and nothing is analysed at all (tsc --noEmit somefile.ts exits 1 here), and TS2307, where the exports map exposes only ./model and ./interface so a deep path does not resolve (exits 2, taking every read in the file with it). Either makes the un-narrowed AND narrowed reads fail together, which reads as a confirmed guarantee. The first version put both in one file, so only the whole-file exit code was observable. Split into schema.consumer-unguarded.ts (must error) and schema.consumer-guarded.ts (must compile), the latter compiled alone by tsconfig.consumer-control.json, which extends the gate's own project and overrides nothing but include, so their settings cannot drift. The two exit codes now read together: gate red + control green is a regressed guarantee, gate red + control red is a broken harness. Re-ran the mutation under the corrected three-observation protocol; results in tests.instructions.md 6.2. The project form also happens to be immune to TS5112, which is a reason it is required rather than merely tidier. Said so in the config, because otherwise someone simplifies it back to a file list. 2. @ts-expect-error is satisfied by ANY error, including the wrong one. When a type weakens, a deep read's error merely changes identity - TS2339 to TS18048 - the directive stays used, and the gate passes while protecting nobody. The assertion degrades from "the property is absent" to "the property is possibly undefined" and nothing can tell. Verified which error each of the four directives actually suppresses by stripping them: all four are TS2339. Written up as 6.1. Also promotes the narrowing finding into the JSDoc on ParseResult and MemberResult, with the measured conversion table, since it is a property of the consumer's compiler rather than of the shape and there is nowhere else a consumer could learn it. The bare form compiles, lints and tests green while removing the discrimination; a numeric payload field read off an un-narrowed result yields undefined, which does not throw but propagates as NaN or takes a default branch, so a failed result flows onward into a computation with the compiler's blessing. Comment-only: 51 insertions into lib/, zero deletions, every changed line inside a JSDoc block, no declaration touched.
…efeats The gate rests entirely on TS2339, "Property 'x' does not exist". Where a type carries an index signature that error cannot fire, so the gate is structurally unable to report anything about it: green means "cannot be checked", not "is safe", and the two are indistinguishable from outside. That is the failure shape this gate was built to detect, aimed at the gate itself. Measured on the shipped declarations: BaseFirestore declares [x: string]: any deliberately, so stored documents predating a change still type-check, and 10 declarations in lib/ extend it (control on a nonsense base name: 0). All are outside the gate's reach. Probed both arms in one run against the shipped types: a key declared nowhere is legal on Ledger.Interface and is TS2339 on Idempotency.Response, so the boundary runs between two types in one namespace. The boundary is NOT "nullable fields cannot be protected" - a nullability guarantee restates cleanly as a presence union and property existence is config-independent. It is "types that admit arbitrary keys cannot be protected". Encoded as base_db.consumer-boundary.ts rather than left as folklore: the blind-spot half must compile, and its compiling IS the recorded limitation; the reachable half is the same regression on an index-free type and is the positive control. The first draft of that fixture did not work, which is the more useful half of this commit. It asserted the reachable arm with @ts-expect-error over a deep read, and adding an index signature to a protected type left the gate GREEN: data became reachable as unknown and the read then failed on .amount instead. The substitute error carried the SAME code, TS2339, on a different subject - "Property 'data' does not exist on type 'ClosedResult'" became "Property 'amount' does not exist on type 'unknown'". Pinning the error code would not have caught it either. So the assertions are now written in the must-compile direction over a KeyIsReachable<T, K> predicate, which cannot be satisfied by a substitute error because it is not satisfied by an error at all. Mutation-validated both ways, control green in each: adding an index signature to a protected type turns the gate red (TS2322 + TS2578), and removing it from BaseFirestore turns it red the other way (TS2322 x2 + TS2339), which is what should happen if the blind spot ever closes. Also documents, at ParseResult, why the bare narrowing form looks like it works: ParseSuccess declares issues?: undefined and message?: undefined so an un-narrowed result can be logged, so those keys exist on both branches and the | undefined collapses. Measured: in the else of if (result.success), where no narrowing occurred, result.issues.length and result.message.toUpperCase() compile clean permissively and error strictly. Those are the fields an error path reaches for, so the failure branch works and the consumer concludes the spelling is fine. The concealment is the hazard, not the two reads, which are correct at runtime on that branch. Comment-only in lib/: 16 insertions, zero deletions, no declaration touched.
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.
Why
This package compiles with
strict,strictNullChecksandnoImplicitAnyall on. Consumers need not, and the same declaration can enforce something here and enforce nothing for them:T | undefinedreduces toTwherestrictNullChecksis off, so the first form's marker collapses andresult.data.amountcompiles clean and throws at runtime.The current types are already the right shape. But nothing enforced that shape — every existing gate runs under this repository's own settings, so all of them are structurally incapable of telling the two forms apart. The unspoken precondition, that a consumer shares our null-checking setting, was true until
strictNullCheckswas enabled here, is now false, and was tracked by nothing.What this adds
Additive only: two tsconfigs, three fixtures, two scripts, two CI steps, docs. No published type changed — the entire
lib/delta is 67 insertions / 0 deletions, every changed line inside a JSDoc block, no declaration touched.tsconfig.consumer.jsontypecheck:consumer*.consumer-unguarded.ts,*.consumer-boundary.ts0tsconfig.consumer-control.jsontypecheck:consumer:control*.consumer-guarded.tsonly0alwaysBoth compile the built
lib/*.d.tsthrough the package's ownexportsmap.--listFilesconfirms onlylib/interface/*.d.tsand the fixture compile — no file fromsrc/.Four findings, each measured
1. A differential needs a control proven alive in the same run
"The un-narrowed read failed to compile" is produced just as readily by a fixture that cannot compile at all, and both routes are live here:
TS5112(files on the command line ⇒ tsconfig never loaded ⇒ nothing analysed;tsc --noEmit somefile.tsexits1here) andTS2307(theexportsmap blocks deep paths; exits2, taking every read down together). Either makes the un-narrowed and narrowed reads fail alike — which reads as a confirmed guarantee.Hence the split: gate red + control green = a guarantee regressed; gate red + control red = the harness broke. The
-pproject form is also immune toTS5112, which is why it is required rather than merely tidier — said so in the config.2.
@ts-expect-erroris satisfied by any error — and even by the same errorThe known form: a deep read's error changes identity
TS2339→TS18048when the type weakens, the directive stays used, the gate passes while protecting nobody. So when the guarantee is property absence, read shallow. Verified all four directives inschema.consumer-unguarded.tssuppressTS2339by stripping them.Sharper, found while building finding 4: the first draft of the boundary fixture used
@ts-expect-errorover a deep read, and the mutation left the gate green — the substitute error carried the same code,TS2339, on a different subject: "Property 'data' does not exist on type 'ClosedResult'" became "Property 'amount' does not exist on type 'unknown'". Pinning the error code would not have caught it. The robust answer is to stop inverting: assert in the must-compile direction over a predicate, which cannot be satisfied by a substitute error because it is not satisfied by an error at all.3. Negative narrowing does not fire where
strictNullChecksis offr.success === false/=== true/'issues' in rr.success ? a : b(false arm) /!r.success/elseThe bare form compiles, lints and tests green while removing the discrimination the parse boundary exists to provide, and reading a missing field does not throw — it yields
undefined, which propagates asNaNor takes a default branch.Why the bare form looks like it works (measured, and now in the
ParseResultJSDoc):ParseSuccessdeclaresissues?: undefinedandmessage?: undefinedso an un-narrowed result can be logged — so those keys exist on both branches and the| undefinedcollapses. In theelseofif (result.success), where no narrowing occurred,result.issues.lengthandresult.message.toUpperCase()compile clean permissively and error strictly. Those are exactly the fields an error path reaches for, so the failure branch works and the consumer concludes the spelling is fine. The concealment is the hazard, not the two reads, which are correct at runtime on that branch.Documented on the types; deliberately not asserted — it is the consumer's compiler, not this package's contract.
4. The gate's coverage boundary — now a test, not folklore
The gate's whole mechanism is
TS2339. Where a type carries an index signature that error cannot fire, so on those types green means "cannot be checked", not "is safe".Verified independently here:
[x: string]: anyatsrc/interface/base_db.ts:46(lib/interface/base_db.d.ts:45); 10 declarations inlib/extendBaseFirestore(control on a nonsense base name:0). Probed both arms in one run against shipped types — a key declared nowhere is legal onLedger.Interfaceand isTS2339onIdempotency.Response, so the boundary runs between two types in a single namespace.The boundary is not "nullable fields cannot be protected" — a nullability guarantee restates cleanly as
{has: true; x: T} | {has: false}and property existence is config-independent. It is "types that admit arbitrary keys cannot be protected."test-consumer/interface/base_db.consumer-boundary.tsencodes it: the blind-spot half must compile (its compiling is the recorded limitation, not an endorsement), paired with the same regression on an index-free type as the control. Both directions mutation-validated, control green in each:TS2322+TS2578)0BaseFirestoreTS2322×2 +TS2339)0Mutation validation of the main gate — the obvious experiment would have been reported as a success
ParseFailuretest/typechecktypecheck:consumer:controlresult.data000data?: undefinedresult.data220result.data.amount000data?: undefinedresult.data.amount02(TS2578×2)0Row 2 is why "reintroduce the marker and watch only the new gate fail" does not work. Row 4 is the divergence, and the control column makes it evidence rather than coincidence. Row 3 rules out "the deep test is simply broken". Identical 2×2 on
member?: undefined/MemberMissbehaves the same.All four directives observed failing under the mutation they exist to catch, control green in the same state.
npm teststayed 919/919 throughout.Conclusion: the strict gate's coverage of this class is incidental to how one line was phrased; the consumer gate's is structural. The shallow phrasing in
test/interface/schema.test.tsis therefore load-bearing and looks exactly like something a reader would tidy into a deep access — documented in place with the measurement.Confirmations requested before merge
lib/is build output, not hand-edited. Proved by reproduction:rm -rf ./lib && npm run buildregenerates all 22 files, thengit status --porcelain -uall -- lib/is EMPTY and the whole tree is EMPTY (2026-08-23T00:30Z). The delta across this branch is comment-only — 67 insertions, 0 deletions, every changed line inside a JSDoc block.tests.instructions.md§6.3 and in the global instructions so a greentypecheck:consumeris never read as blanket coverage.Found and deliberately not fixed
MemberMatchhas novalue?: undefinedmirror. Correct as-is — the omission is the backstop. Documented instead of changed; this PR alters no published type.npm ci. Pre-existing, untouched.Gates (2026-08-23T00:30Z)
build0· clean-rebuildlib/drift EMPTY ·lint0·test0(919/919) ·typecheck0·typecheck:consumer0·typecheck:consumer:control0· private-marker check0.Attribution over
origin/main..HEAD: 0 trailers, single identity, author == committer. Positive-controlled — the same grep returns1on a synthetic trailer.