feat: add checked enum narrowing, and close an inert guarantee in ParseResult - #11
Merged
Conversation
`ParseFailure` declared `data?: undefined` as a sibling marker, and its
documentation claimed a caller "cannot reach a typed document without
first narrowing on `success`". That claim was false wherever it mattered.
With `strictNullChecks: false` — which this package and its consumers all
compile under — `undefined` is assignable to every type, so the marker
collapses. Measured on the merged tree:
const result = Ledger.safeParse({service: 's', scope: 'sc', amount: 'abc'});
const amount: number = result.data.amount;
compiles with no error and throws `TypeError` at runtime. The control was
inert exactly where it was documented to hold.
Omitting the property from the failure branch entirely produces
`Property 'data' does not exist on type 'ParseFailure'` regardless of the
null-checking setting, which is the only form of the guarantee that
actually fires here.
The asymmetry with `ParseSuccess` is kept deliberately: reading `.issues`
off a success yields `undefined` where none exist, which is wrong but
benign and convenient when logging an un-narrowed result. Reading `.data`
off a failure yields an absent value typed as a valid document, which is
the defect this module exists to prevent. Only the dangerous direction is
closed.
Adds a `@ts-expect-error` regression guard so the error cannot silently
stop occurring: if a sibling marker is reintroduced the directive becomes
unused and `npm run typecheck` fails. `npm test` cannot observe this,
which is the point.
This narrows a published type. It is deliberate and the window is now:
the type was published minutes ago and no consumer compiles against it
yet.
Some fields in this package are deliberately typed `string` rather than an enum, because the vocabulary is owned by the service that writes them and a partial copy here would reject legitimate records. That layering is correct — this package validates shape, the vocabulary's owner validates membership — but on its own it only moves the cast rather than removing it. A caller still writes `parsed.service as Service`, which checks nothing, and relying on every call site to narrow instead is a rule rather than a mechanism. `matchMember(members, value)` returns a discriminated `MemberResult<T>`: a match carrying the typed member, or a miss carrying the offending value. The cast is written once, here, inside a guard that has actually checked. `requireMember(members, value, label)` is the throwing counterpart, mirroring the relationship between `parseOrThrow` and `parseResult`. The miss branch deliberately has **no `member` property at all**, for the same measured reason as `ParseFailure`: under `strictNullChecks: false` a `T | undefined` return type collapses to `T`, so the unhandled case would compile cleanly and misroute silently. Measured, in this repository's own configuration: | return shape | unhandled access | |-------------------------------------|------------------| | `T \| undefined` | compiles | | discriminated, `member?: undefined` | compiles | | discriminated, property omitted | compile error | Only the third makes the mistake unrepresentable rather than merely discouraged, so that is the shape used. Membership rules, stated because they are policy rather than mechanics: a miss is anything that is not exactly one of the declared values, which includes a non-string, `null`, `undefined`, the empty string, and any difference in casing or surrounding whitespace. Nothing is normalised, because normalising means guessing which near-miss the writer intended. Absence and invalidity are both misses but stay distinguishable through `MemberMiss.value`, so a caller for whom an absent field is acceptable and a wrong one is not can tell them apart. Additive: new exports only, no existing type or behaviour changed.
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.
Follow-up to #10. Two commits, deliberately separable — the first is a narrowing, the second is purely additive. Drop either independently.
Commit 2 (additive):
matchMember/requireMemberThe requested affordance.
Ledger.serviceis a validatedstringbecause the vocabulary is owned by the metering service, which is the right layering — but on its own it only moves the cast: a caller still writesparsed.service as Service, which checks nothing. Telling every call site to narrow instead is a rule, not a mechanism, and relying on discipline at every call site is what failed the first time.requireMember(members, value, label)is the throwing counterpart, mirroringparseOrThrow/parseResult.Membership policy, stated explicitly
A miss is anything that is not exactly one of the declared values: a non-string,
null,undefined, the empty string, and any difference in casing or surrounding whitespace. Nothing is normalised, because normalising means guessing which near-miss the writer intended.Absence and invalidity are both misses but stay distinguishable via
MemberMiss.value—undefinedfor an absent field,nullfor an explicit null, the offending string otherwise. Which of those warrants an error is the caller's policy, so this function does not decide it for them.Commit 1 (narrowing): an inert guarantee in #10
While measuring the return shape for
memberOfI found that a guarantee I documented in #10 does not fire.ParseFailuredeclareddata?: undefinedand its docs claimed a caller "cannot reach a typed document without first narrowing onsuccess". Measured on mergedmain:Under
strictNullChecks: falseundefinedis assignable to everything, so the sibling marker collapses. I checked the consumer: it is alsostrictNullChecks: false, so this was inert exactly where it was documented to hold — false confidence in a control that never fires, which is worse than a documented gap.Omitting the property from the failure branch entirely gives
Property 'data' does not exist on type 'ParseFailure'regardless of the flag.Kept asymmetric on purpose. Reading
.issuesoff a success yieldsundefinedwhere none exist — wrong but benign, and convenient when logging an un-narrowed result. Reading.dataoff a failure yields an absent value typed as a valid document. Only the dangerous direction is closed; the 40 benign call sites are untouched, and only 7 needed narrowing.This narrows a published type, so it is not additive and I have kept it separate. The window is now: #10 landed minutes ago and the
functionspin is still ondf3559a5…, so nothing compiles against it yet.You asked for
memberOf(...): Service | undefined. That form does not satisfy your own requirement 2 in this repo. Measured, all three forms, same file, both configurations:strictNullChecks: false)T | undefinedmember?: undefinedSince the consumer is also non-strict, only the third form makes the mistake unrepresentable today rather than after a flag flip. That is why this is
MemberResult<T>rather thanT | undefined. Row 2 is the trap — it looks discriminated but isn't, and it is precisely the mistake commit 1 fixes.Evidence — 7/7 detected, across both gates
npm testnpm run typecheckmatchMemberaccept anythingmatchMembernormalise casingmatchMemberaccept non-stringsrequireMemberreturn instead of throwMemberMissdrop the carried valueParseFailurereintroduce the collapsing markerMemberMissreintroduce the collapsing markerThe last two rows are the compile-time half you asked for. Reintroducing either marker leaves the suite fully green and turns
typecheckred — so the guarantee has a real regression guard, andnpm testprovably cannot see it.That guard is a committed
@ts-expect-errorwith a description, not a comment: it asserts the line under it is an error, so if the error stops occurring the directive becomes unused and typecheck fails. It runs under the existingnpm run typecheckgate, which already includestest/— no new tooling and no CI change.Gates
npm run buildnpm testnpm run typechecknpm run lintgit status --porcelain -uall -- lib/Erny Sansonlylib/regenerated and committed in each commit, so either can be reverted without leaving stale output.The barrel inventory test from #10 caught the two new exports and had to be updated — that control working as intended.