diff --git a/lib/interface/schema.d.ts b/lib/interface/schema.d.ts index c36d98b..574151b 100644 --- a/lib/interface/schema.d.ts +++ b/lib/interface/schema.d.ts @@ -156,20 +156,30 @@ export interface ParseSuccess { /** * Failed outcome of a parse. * - * There is deliberately no `data` on this branch. A caller cannot reach a typed - * document without first narrowing on `success`, which is the property that - * makes validation unskippable rather than merely available. + * There is deliberately **no `data` property on this branch at all** — not even + * an optional `data?: undefined`. That distinction is load-bearing rather than + * stylistic, and it was measured rather than assumed. + * + * With `strictNullChecks: false`, which both this package and its consumers + * compile under, `undefined` is assignable to every type. So a sibling marker of + * the form `data?: undefined` **collapses**, and `result.data.amount` on an + * un-narrowed {@link ParseResult} compiles cleanly and throws `TypeError` at + * runtime. Omitting the property 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 {@link ParseSuccess} is deliberate. Reading `.issues` off a + * success yields `undefined` where a caller expected none to 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, which is the exact + * defect this whole module exists to prevent. Only the dangerous direction is + * closed. */ export interface ParseFailure { /** * Discriminant. Always `false` on this branch. */ success: false; - /** - * Always absent on failure, so an unvalidated document can never be read out - * of a failed result. - */ - data?: undefined; /** * Every reason the document was rejected, not just the first. */ @@ -474,3 +484,105 @@ export declare const auditTimestamp: () => z.ZodType} Schema accepting any value, including absence. */ export declare const openValue: () => z.ZodOptional; +/** + * Successful narrowing of an untrusted value to a member of a caller-owned + * enumeration. + * + * @template TMember The enumeration's member type. + */ +export interface MemberMatch { + /** + * Discriminant. Always `true` on this branch. + */ + matched: true; + /** + * The value, now typed as a member of the enumeration. + */ + member: TMember; +} +/** + * Failed narrowing: the value is not a member of the enumeration. + * + * There is deliberately **no `member` property on this branch at all**, for the + * same measured reason as {@link ParseFailure}: under `strictNullChecks: false` + * a sibling `member?: undefined` marker collapses, and reading `.member` off an + * un-narrowed result would compile cleanly. Omitting it makes the unhandled case + * a compile error regardless of the null-checking setting. + */ +export interface MemberMiss { + /** + * Discriminant. Always `false` on this branch. + */ + matched: false; + /** + * The value that failed to match, carried so a caller can distinguish the + * cases that {@link matchMember} deliberately does not distinguish for them. + * + * `undefined` means the field was absent, `null` means it was explicitly null, + * and anything else is a value that was present but is not a member. All three + * are misses; which of them warrants an error is the caller's policy, not this + * function's. + */ + value: unknown; +} +/** + * Result of narrowing an untrusted value to a member of an enumeration. + * + * @template TMember The enumeration's member type. + */ +export type MemberResult = MemberMatch | MemberMiss; +/** + * Narrows an untrusted value to a member of a caller-owned enumeration. + * + * ## Why this exists + * + * 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 is the correct + * layering — 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. + * + * This closes that. The cast is written once, here, inside a guard that has + * actually checked, instead of once per call site inside nothing. And because + * the miss branch carries no `member` property, a caller that ignores the failure + * gets a compile error rather than a silent misroute — the mistake is + * unrepresentable rather than merely discouraged. + * + * ## What counts as a miss + * + * Everything that is not exactly one of the enumeration's values: a non-string, + * `null`, `undefined`, the empty string (unless the enumeration declares an + * empty member, which none should), and any string with different casing or + * surrounding whitespace. No normalisation is performed, because normalising + * would mean guessing which near-miss the writer intended. + * + * Absence and invalidity are both misses. They are **distinguishable** through + * {@link MemberMiss.value}, so a caller for whom an absent field is acceptable + * but a wrong one is not can tell them apart; the function does not decide that + * policy on the caller's behalf. + * + * @template TEnum The enumeration object, typically `typeof SomeEnum`. + * @param {TEnum} members - The enumeration to narrow against. + * @param {unknown} value - Untrusted value, typically a field of an already-parsed document. + * @return {MemberResult} A match carrying the typed member, or a miss carrying the offending value. + */ +export declare const matchMember: >(members: TEnum, value: unknown) => MemberResult; +/** + * Narrows an untrusted value to a member of an enumeration, throwing when it is + * not one. + * + * The throwing counterpart to {@link matchMember}, mirroring the relationship + * between {@link parseOrThrow} and {@link parseResult}. Use it where continuing + * with an unrecognised value is never the right outcome, so the failure surfaces + * at the boundary the value entered rather than as a dispatch with no matching + * branch several layers later. + * + * @template TEnum The enumeration object, typically `typeof SomeEnum`. + * @param {TEnum} members - The enumeration to narrow against. + * @param {unknown} value - Untrusted value, typically a field of an already-parsed document. + * @param {string} label - Name of the field being narrowed, used in the thrown message. + * @return {TEnum[keyof TEnum]} The value, typed as a member of the enumeration. + * @throws {ParseError} When the value is not a member. + */ +export declare const requireMember: >(members: TEnum, value: unknown, label: string) => TEnum[keyof TEnum]; diff --git a/lib/interface/schema.js b/lib/interface/schema.js index 5b9afac..794e2d3 100644 --- a/lib/interface/schema.js +++ b/lib/interface/schema.js @@ -323,3 +323,75 @@ export const auditTimestamp = () => z.custom((candidate) => { * @return {z.ZodOptional} Schema accepting any value, including absence. */ export const openValue = () => z.unknown().optional(); +/** + * Narrows an untrusted value to a member of a caller-owned enumeration. + * + * ## Why this exists + * + * 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 is the correct + * layering — 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. + * + * This closes that. The cast is written once, here, inside a guard that has + * actually checked, instead of once per call site inside nothing. And because + * the miss branch carries no `member` property, a caller that ignores the failure + * gets a compile error rather than a silent misroute — the mistake is + * unrepresentable rather than merely discouraged. + * + * ## What counts as a miss + * + * Everything that is not exactly one of the enumeration's values: a non-string, + * `null`, `undefined`, the empty string (unless the enumeration declares an + * empty member, which none should), and any string with different casing or + * surrounding whitespace. No normalisation is performed, because normalising + * would mean guessing which near-miss the writer intended. + * + * Absence and invalidity are both misses. They are **distinguishable** through + * {@link MemberMiss.value}, so a caller for whom an absent field is acceptable + * but a wrong one is not can tell them apart; the function does not decide that + * policy on the caller's behalf. + * + * @template TEnum The enumeration object, typically `typeof SomeEnum`. + * @param {TEnum} members - The enumeration to narrow against. + * @param {unknown} value - Untrusted value, typically a field of an already-parsed document. + * @return {MemberResult} A match carrying the typed member, or a miss carrying the offending value. + */ +export const matchMember = (members, value) => { + if (typeof value !== 'string') + return { matched: false, value }; + const accepted = Object.values(members); + if (!accepted.includes(value)) + return { matched: false, value }; + return { matched: true, member: value }; +}; +/** + * Narrows an untrusted value to a member of an enumeration, throwing when it is + * not one. + * + * The throwing counterpart to {@link matchMember}, mirroring the relationship + * between {@link parseOrThrow} and {@link parseResult}. Use it where continuing + * with an unrecognised value is never the right outcome, so the failure surfaces + * at the boundary the value entered rather than as a dispatch with no matching + * branch several layers later. + * + * @template TEnum The enumeration object, typically `typeof SomeEnum`. + * @param {TEnum} members - The enumeration to narrow against. + * @param {unknown} value - Untrusted value, typically a field of an already-parsed document. + * @param {string} label - Name of the field being narrowed, used in the thrown message. + * @return {TEnum[keyof TEnum]} The value, typed as a member of the enumeration. + * @throws {ParseError} When the value is not a member. + */ +export const requireMember = (members, value, label) => { + const result = matchMember(members, value); + if (result.matched) + return result.member; + const issues = [{ + path: label, + code: 'invalid_value', + message: `Expected one of: ${Object.values(members).join(', ')}`, + }]; + throw new ParseError(toMessage(label, issues), issues); +}; diff --git a/src/interface/schema.ts b/src/interface/schema.ts index 0c45c00..41f07cc 100644 --- a/src/interface/schema.ts +++ b/src/interface/schema.ts @@ -169,20 +169,30 @@ export interface ParseSuccess { /** * Failed outcome of a parse. * - * There is deliberately no `data` on this branch. A caller cannot reach a typed - * document without first narrowing on `success`, which is the property that - * makes validation unskippable rather than merely available. + * There is deliberately **no `data` property on this branch at all** — not even + * an optional `data?: undefined`. That distinction is load-bearing rather than + * stylistic, and it was measured rather than assumed. + * + * With `strictNullChecks: false`, which both this package and its consumers + * compile under, `undefined` is assignable to every type. So a sibling marker of + * the form `data?: undefined` **collapses**, and `result.data.amount` on an + * un-narrowed {@link ParseResult} compiles cleanly and throws `TypeError` at + * runtime. Omitting the property 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 {@link ParseSuccess} is deliberate. Reading `.issues` off a + * success yields `undefined` where a caller expected none to 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, which is the exact + * defect this whole module exists to prevent. Only the dangerous direction is + * closed. */ export interface ParseFailure { /** * Discriminant. Always `false` on this branch. */ success: false; - /** - * Always absent on failure, so an unvalidated document can never be read out - * of a failed result. - */ - data?: undefined; /** * Every reason the document was rejected, not just the first. */ @@ -570,3 +580,131 @@ export const auditTimestamp = (): z.ZodType} Schema accepting any value, including absence. */ export const openValue = (): z.ZodOptional => z.unknown().optional(); + +/** + * Successful narrowing of an untrusted value to a member of a caller-owned + * enumeration. + * + * @template TMember The enumeration's member type. + */ +export interface MemberMatch { + /** + * Discriminant. Always `true` on this branch. + */ + matched: true; + /** + * The value, now typed as a member of the enumeration. + */ + member: TMember; +} + +/** + * Failed narrowing: the value is not a member of the enumeration. + * + * There is deliberately **no `member` property on this branch at all**, for the + * same measured reason as {@link ParseFailure}: under `strictNullChecks: false` + * a sibling `member?: undefined` marker collapses, and reading `.member` off an + * un-narrowed result would compile cleanly. Omitting it makes the unhandled case + * a compile error regardless of the null-checking setting. + */ +export interface MemberMiss { + /** + * Discriminant. Always `false` on this branch. + */ + matched: false; + /** + * The value that failed to match, carried so a caller can distinguish the + * cases that {@link matchMember} deliberately does not distinguish for them. + * + * `undefined` means the field was absent, `null` means it was explicitly null, + * and anything else is a value that was present but is not a member. All three + * are misses; which of them warrants an error is the caller's policy, not this + * function's. + */ + value: unknown; +} + +/** + * Result of narrowing an untrusted value to a member of an enumeration. + * + * @template TMember The enumeration's member type. + */ +export type MemberResult = MemberMatch | MemberMiss; + +/** + * Narrows an untrusted value to a member of a caller-owned enumeration. + * + * ## Why this exists + * + * 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 is the correct + * layering — 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. + * + * This closes that. The cast is written once, here, inside a guard that has + * actually checked, instead of once per call site inside nothing. And because + * the miss branch carries no `member` property, a caller that ignores the failure + * gets a compile error rather than a silent misroute — the mistake is + * unrepresentable rather than merely discouraged. + * + * ## What counts as a miss + * + * Everything that is not exactly one of the enumeration's values: a non-string, + * `null`, `undefined`, the empty string (unless the enumeration declares an + * empty member, which none should), and any string with different casing or + * surrounding whitespace. No normalisation is performed, because normalising + * would mean guessing which near-miss the writer intended. + * + * Absence and invalidity are both misses. They are **distinguishable** through + * {@link MemberMiss.value}, so a caller for whom an absent field is acceptable + * but a wrong one is not can tell them apart; the function does not decide that + * policy on the caller's behalf. + * + * @template TEnum The enumeration object, typically `typeof SomeEnum`. + * @param {TEnum} members - The enumeration to narrow against. + * @param {unknown} value - Untrusted value, typically a field of an already-parsed document. + * @return {MemberResult} A match carrying the typed member, or a miss carrying the offending value. + */ +export const matchMember = >( + members: TEnum, + value: unknown, +): MemberResult => { + if (typeof value !== 'string') return {matched: false, value}; + const accepted = Object.values(members) as string[]; + if (!accepted.includes(value)) return {matched: false, value}; + return {matched: true, member: value as TEnum[keyof TEnum]}; +}; + +/** + * Narrows an untrusted value to a member of an enumeration, throwing when it is + * not one. + * + * The throwing counterpart to {@link matchMember}, mirroring the relationship + * between {@link parseOrThrow} and {@link parseResult}. Use it where continuing + * with an unrecognised value is never the right outcome, so the failure surfaces + * at the boundary the value entered rather than as a dispatch with no matching + * branch several layers later. + * + * @template TEnum The enumeration object, typically `typeof SomeEnum`. + * @param {TEnum} members - The enumeration to narrow against. + * @param {unknown} value - Untrusted value, typically a field of an already-parsed document. + * @param {string} label - Name of the field being narrowed, used in the thrown message. + * @return {TEnum[keyof TEnum]} The value, typed as a member of the enumeration. + * @throws {ParseError} When the value is not a member. + */ +export const requireMember = >( + members: TEnum, + value: unknown, + label: string, +): TEnum[keyof TEnum] => { + const result = matchMember(members, value); + if (result.matched) return result.member; + const issues: ParseIssue[] = [{ + path: label, + code: 'invalid_value', + message: `Expected one of: ${Object.values(members).join(', ')}`, + }]; + throw new ParseError(toMessage(label, issues), issues); +}; diff --git a/test/interface/index.test.ts b/test/interface/index.test.ts index 6bd3285..c2d1778 100644 --- a/test/interface/index.test.ts +++ b/test/interface/index.test.ts @@ -76,6 +76,7 @@ describe('interface barrel completeness', () => { 'epochSeconds', 'finiteNumber', 'isTimestampLike', + 'matchMember', 'messageQueueShape', 'nonEmptyString', 'nonNegativeNumber', @@ -85,6 +86,7 @@ describe('interface barrel completeness', () => { 'parsePlaceData', 'parseResult', 'placeDataShape', + 'requireMember', 'requiredKey', 'safeParseMessageQueue', 'safeParsePlaceData', diff --git a/test/interface/schema.test.ts b/test/interface/schema.test.ts index 8bafed9..d284904 100644 --- a/test/interface/schema.test.ts +++ b/test/interface/schema.test.ts @@ -13,12 +13,14 @@ import { epochSeconds, finiteNumber, isTimestampLike, + matchMember, nonEmptyString, nonNegativeNumber, openValue, ParseError, parseOrThrow, parseResult, + requireMember, requiredKey, timestampLike, token, @@ -300,14 +302,15 @@ describe('parse plumbing', () => { it('should return a success branch carrying the data', () => { const result = parseResult(schema, {account: 'acc_synthetic', amount: 100}, 'Fixture'); expect(result.success).toBe(true); - expect(result.data).toEqual({account: 'acc_synthetic', amount: 100}); + // Narrowing on `success` is the only route to the document, by construction. + expect(result.success && result.data).toEqual({account: 'acc_synthetic', amount: 100}); expect(result.issues).toBeUndefined(); }); it('should return a failure branch with no data at all', () => { const result = parseResult(schema, {amount: 100}, 'Fixture'); expect(result.success).toBe(false); - expect(result.data).toBeUndefined(); + expect(result.success ? result.data : undefined).toBeUndefined(); }); it('should report every reason, not only the first', () => { @@ -361,7 +364,7 @@ describe('parse plumbing', () => { it('should preserve an unknown key rather than dropping it', () => { const result = parseResult(schema, {account: 'acc_synthetic', legacyField: 'kept'}, 'Fixture'); expect(result.success).toBe(true); - expect(result.data?.['legacyField']).toBe('kept'); + expect(result.success && result.data['legacyField']).toBe('kept'); }); it('should demonstrate the contrast with a stripping schema, which deletes it silently', () => { @@ -373,7 +376,7 @@ describe('parse plumbing', () => { it('should preserve a nested unknown value by reference', () => { const nested = {deep: true}; const result = parseResult(schema, {account: 'acc_synthetic', extra: nested}, 'Fixture'); - expect(result.data?.['extra']).toBe(nested); + expect(result.success && result.data['extra']).toBe(nested); }); }); }); @@ -620,8 +623,151 @@ describe('null and optionality policy', () => { it('should distinguish a null nullable field from an absent one after parsing', () => { const withNull = Ledger.safeParse({service: 's', scope: 'sc', amount: 1, limit: null}); const withoutLimit = Ledger.safeParse({service: 's', scope: 'sc', amount: 1}); - expect(withNull.data?.limit).toBeNull(); - expect(withoutLimit.data && 'limit' in withoutLimit.data).toBe(false); + expect(withNull.success && withNull.data.limit).toBeNull(); + expect(withoutLimit.success && 'limit' in withoutLimit.data).toBe(false); }); }); }); + +/** + * A caller-owned enumeration, standing in for a vocabulary this package + * deliberately does not declare. Declared here so the suite depends on no + * external vocabulary. + */ +enum LocalService { + alpha = 'alpha', + beta = 'beta', +} + +describe('member narrowing', () => { + describe('matchMember', () => { + it('should match a declared member and carry it typed', () => { + const result = matchMember(LocalService, 'alpha'); + expect(result.matched).toBe(true); + expect(result.matched && result.member).toBe(LocalService.alpha); + }); + + it('should match every declared member', () => { + for (const member of Object.values(LocalService)) { + expect(matchMember(LocalService, member).matched).toBe(true); + } + }); + + it('should miss a string that is not a member', () => { + const result = matchMember(LocalService, 'gamma'); + expect(result.matched).toBe(false); + expect(result.matched === false && result.value).toBe('gamma'); + }); + + it('should miss on casing or whitespace rather than normalising', () => { + for (const candidate of ['Alpha', 'ALPHA', ' alpha', 'alpha ']) { + expect(matchMember(LocalService, candidate).matched).toBe(false); + } + }); + + it('should miss the empty string', () => { + const result = matchMember(LocalService, ''); + expect(result.matched).toBe(false); + expect(result.matched === false && result.value).toBe(''); + }); + + it('should miss null and undefined, carrying each so absence stays distinguishable from invalidity', () => { + const absent = matchMember(LocalService, undefined); + const explicitNull = matchMember(LocalService, null); + const wrong = matchMember(LocalService, 'gamma'); + expect([absent.matched, explicitNull.matched, wrong.matched]).toEqual([false, false, false]); + expect(absent.matched === false && absent.value).toBeUndefined(); + expect(explicitNull.matched === false && explicitNull.value).toBeNull(); + expect(wrong.matched === false && wrong.value).toBe('gamma'); + }); + + it('should miss a non-string of any kind', () => { + for (const candidate of [1, true, {member: 'alpha'}, ['alpha']]) { + expect(matchMember(LocalService, candidate).matched).toBe(false); + } + }); + + it('should compose with a parsed document, which is the intended call shape', () => { + const record = Ledger.parse({service: 'alpha', scope: 'scope_synthetic', amount: 1}); + const result = matchMember(LocalService, record.service); + expect(result.matched && result.member).toBe(LocalService.alpha); + }); + + it('should miss when a parsed document carries a service this caller does not know', () => { + const record = Ledger.parse({service: 'omega', scope: 'scope_synthetic', amount: 1}); + expect(matchMember(LocalService, record.service).matched).toBe(false); + }); + }); + + describe('requireMember', () => { + it('should return the typed member when the value is one', () => { + expect(requireMember(LocalService, 'beta', 'service')).toBe(LocalService.beta); + }); + + it('should throw a ParseError naming the field and the accepted values', () => { + expect(() => requireMember(LocalService, 'gamma', 'service')).toThrow(ParseError); + expect(() => requireMember(LocalService, 'gamma', 'service')).toThrow(/service failed validation/); + expect(() => requireMember(LocalService, 'gamma', 'service')).toThrow(/alpha, beta/); + }); + + it('should throw on absence as well as on an unrecognised value', () => { + expect(() => requireMember(LocalService, undefined, 'service')).toThrow(ParseError); + expect(() => requireMember(LocalService, null, 'service')).toThrow(ParseError); + }); + + it('should carry a structured issue at the field path', () => { + let thrown: unknown; + try { + requireMember(LocalService, 'gamma', 'service'); + } catch (error) { + thrown = error; + } + expect((thrown as ParseError).issues[0]?.path).toBe('service'); + expect((thrown as ParseError).issues[0]?.code).toBe('invalid_value'); + }); + }); +}); + +/** + * Compile-time guarantees, asserted with `@ts-expect-error`. + * + * These are the whole value of the discriminated shapes, and they are otherwise + * untestable: a runtime assertion cannot observe a type. Each directive below + * asserts that the line under it **is** a compile error, so if the error ever + * stops occurring — for example because someone re-adds a `data?: undefined` or + * `member?: undefined` sibling marker — the unused directive becomes an error + * itself and `npm run typecheck` goes red. + * + * That inversion is what makes this a regression guard rather than a comment. + * It is enforced by the existing `npm run typecheck` gate, which includes + * `test/`, so no new tooling is involved. + * + * The mechanism matters because the obvious alternative does not work here. + * Under this repository's `strictNullChecks: false` a `T | undefined` return + * type collapses to `T`, so the unhandled case would compile cleanly; only the + * absence of the property from the other branch survives that setting. + */ +describe('compile-time guarantees', () => { + it('should make an un-narrowed data access a compile error', () => { + const result = Ledger.safeParse({service: 's', scope: 'sc', amount: 'abc'}); + // @ts-expect-error data is absent from the failure branch, so reading it without narrowing on success must not compile. + const unguarded = result.data; + expect(unguarded).toBeUndefined(); + // The guarded form compiles and is the only way to reach the document. + expect(result.success ? result.data : undefined).toBeUndefined(); + }); + + it('should make an un-narrowed member access a compile error', () => { + const result = matchMember(LocalService, 'gamma'); + // @ts-expect-error member is absent from the miss branch, so reading it without narrowing on matched must not compile. + const unguarded = result.member; + expect(unguarded).toBeUndefined(); + expect(result.matched ? result.member : undefined).toBeUndefined(); + }); + + it('should still allow inspecting issues on an un-narrowed result, the benign direction', () => { + const result = Ledger.safeParse({service: 's', scope: 'sc', amount: 'abc'}); + expect(result.issues?.length).toBeGreaterThan(0); + expect(result.message).toContain('Ledger.Interface'); + }); +});