Skip to content

test: compile published declarations under consumer conditions - #15

Merged
ernysans merged 4 commits into
mainfrom
ernysans-consumer-conditions-type-test
Aug 23, 2026
Merged

test: compile published declarations under consumer conditions#15
ernysans merged 4 commits into
mainfrom
ernysans-consumer-conditions-type-test

Conversation

@ernysans

@ernysans ernysans commented Aug 22, 2026

Copy link
Copy Markdown
Member

Why

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:

interface ParseFailure { success: false; data?: undefined }  // ❌ rests on null-checking
interface ParseFailure { success: false }                    // ✅ rests on property existence

T | undefined reduces to T where strictNullChecks is off, so the first form's marker collapses and result.data.amount compiles 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 strictNullChecks was 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.

project script fixture must
tsconfig.consumer.json typecheck:consumer *.consumer-unguarded.ts, *.consumer-boundary.ts exit 0
tsconfig.consumer-control.json typecheck:consumer:control *.consumer-guarded.ts only exit 0 always

Both compile the built lib/*.d.ts through the package's own exports map. --listFiles confirms only lib/interface/*.d.ts and the fixture compile — no file from src/.

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.ts exits 1 here) and TS2307 (the exports map blocks deep paths; exits 2, 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 -p project form is also immune to TS5112, which is why it is required rather than merely tidier — said so in the config.

2. @ts-expect-error is satisfied by any error — and even by the same error

The known form: a deep read's error changes identity TS2339TS18048 when 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 in schema.consumer-unguarded.ts suppress TS2339 by stripping them.

Sharper, found while building finding 4: the first draft of the boundary fixture used @ts-expect-error over 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 strictNullChecks is off

form flag on flag off
r.success === false / === true / 'issues' in r narrows narrows
r.success ? a : b (false arm) / !r.success / else narrows does not narrow

The 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 as NaN or takes a default branch.

Why the bare form looks like it works (measured, and now in the ParseResult JSDoc): 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. 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 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]: any at src/interface/base_db.ts:46 (lib/interface/base_db.d.ts:45); 10 declarations in lib/ extend BaseFirestore (control on a nonsense base name: 0). Probed both arms in one run against 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 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.ts encodes 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:

mutation gate control
index signature added to a protected type red (TS2322 + TS2578) green 0
index signature removed from BaseFirestore red (TS2322 ×2 + TS2339) green 0

Mutation validation of the main gate — the obvious experiment would have been reported as a success

ParseFailure strict assertion form in test/ typecheck typecheck:consumer :control
omitted (as shipped) shallow result.data green 0 green 0 green 0
data?: undefined shallow result.data red 2 red 2 green 0
omitted (as shipped) deep result.data.amount green 0 green 0 green 0
data?: undefined deep result.data.amount green 0 red 2 (TS2578 ×2) green 0

Row 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 / MemberMiss behaves the same.

All four directives observed failing under the mutation they exist to catch, control green in the same state. npm test stayed 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.ts is 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

  1. lib/ is build output, not hand-edited. Proved by reproduction: rm -rf ./lib && npm run build regenerates all 22 files, then git 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.
  2. CI ordering holds. Build (L41) → drift check (L61) → private markers (L81) → test (L94) → typecheck (L106) → typecheck:consumer (L142)control (L163). The consumer gates read compiled output, so they run after the build and after the drift check.
  3. Blind spot encoded as a test — finding 4 above, plus one line in tests.instructions.md §6.3 and in the global instructions so a green typecheck:consumer is never read as blanket coverage.

Found and deliberately not fixed

  • MemberMatch has no value?: undefined mirror. Correct as-is — the omission is the backstop. Documented instead of changed; this PR alters no published type.
  • 13 npm audit findings (11 moderate, 2 high) from npm ci. Pre-existing, untouched.

Gates (2026-08-23T00:30Z)

build 0 · clean-rebuild lib/ drift EMPTY · lint 0 · test 0 (919/919) · typecheck 0 · typecheck:consumer 0 · typecheck:consumer:control 0 · private-marker check 0.

Attribution over origin/main..HEAD: 0 trailers, single identity, author == committer. Positive-controlled — the same grep returns 1 on a synthetic trailer.

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.
@ernysans
ernysans merged commit 4b823ce into main Aug 23, 2026
2 checks passed
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