Skip to content

feat: add checked enum narrowing, and close an inert guarantee in ParseResult - #11

Merged
ernysans merged 2 commits into
mainfrom
ernysans-member-narrowing
Aug 22, 2026
Merged

feat: add checked enum narrowing, and close an inert guarantee in ParseResult#11
ernysans merged 2 commits into
mainfrom
ernysans-member-narrowing

Conversation

@ernysans

Copy link
Copy Markdown
Member

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 / requireMember

The requested affordance. Ledger.service is a validated string because 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 writes parsed.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.

const result = matchMember(Service, record.service);
if (!result.matched) return refuse(result.value);
doWork(result.member);            // Service, checked

requireMember(members, value, label) is the throwing counterpart, mirroring parseOrThrow / 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.valueundefined for an absent field, null for 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 memberOf I found that a guarantee I documented in #10 does not fire. ParseFailure declared data?: undefined and its docs claimed a caller "cannot reach a typed document without first narrowing on success". Measured on merged main:

const result = Ledger.safeParse({service: 's', scope: 'sc', amount: 'abc'});
const amount: number = result.data.amount;   // compiles clean; TypeError at runtime

Under strictNullChecks: false undefined is assignable to everything, so the sibling marker collapses. I checked the consumer: it is also strictNullChecks: 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 .issues off a success yields undefined where none exist — 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. 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 functions pin is still on df3559a5…, so nothing compiles against it yet.


⚠️ I deviated from the requested API — flagging it, per the lesson from #10

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:

return shape this repo (strictNullChecks: false) a strict consumer
T | undefined compiles compile error ✓
discriminated, member?: undefined compiles compile error ✓
discriminated, property omitted compile error compile error ✓

Since 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 than T | 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

mutation npm test npm run typecheck
matchMember accept anything RED (8) RED
matchMember normalise casing RED (1) green
matchMember accept non-strings RED (3) green
requireMember return instead of throw RED (16) RED
MemberMiss drop the carried value RED (1) green
ParseFailure reintroduce the collapsing marker green RED
MemberMiss reintroduce the collapsing marker green RED

The last two rows are the compile-time half you asked for. Reintroducing either marker leaves the suite fully green and turns typecheck red — so the guarantee has a real regression guard, and npm test provably cannot see it.

That guard is a committed @ts-expect-error with 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 existing npm run typecheck gate, which already includes test/no new tooling and no CI change.

Gates

Gate Result
npm run build exit 0
npm test 919 passed, 18 files (was 903)
npm run typecheck exit 0
npm run lint exit 0
git status --porcelain -uall -- lib/ empty after rebuild
private-marker guard exit 0
trailers / authorship in range 0 / Erny Sans only

lib/ 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.

`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.
@ernysans
ernysans merged commit 108d297 into main Aug 22, 2026
2 checks passed
@ernysans
ernysans deleted the ernysans-member-narrowing branch August 22, 2026 23:15
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