From 572146dcc681aa0679e983686aed88bc832b1066 Mon Sep 17 00:00:00 2001 From: Franco Pablo Romano Losada Date: Mon, 22 Dec 2025 12:03:12 +0000 Subject: [PATCH 1/4] Add internal boundary error handling and tighten errata typing --- packages/errata/src/client.ts | 122 ++++++---- packages/errata/src/errata.ts | 214 ++++++++++++------ packages/errata/src/index.ts | 10 + packages/errata/src/types.ts | 100 ++++++-- packages/errata/test/client-plugins.test.ts | 4 +- packages/errata/test/client.test.ts | 39 +++- packages/errata/test/errata.test.ts | 17 +- packages/errata/test/pattern-matching.test.ts | 41 ++-- 8 files changed, 363 insertions(+), 184 deletions(-) diff --git a/packages/errata/src/client.ts b/packages/errata/src/client.ts index 6760f1a..21ab307 100644 --- a/packages/errata/src/client.ts +++ b/packages/errata/src/client.ts @@ -6,10 +6,13 @@ import type { CodeOf, CodesForTag, CodesRecord, + ErrataClientErrorForCodes, ErrataClientPlugin, - MatchingErrataClientError, - Pattern, - PatternInput, + InternalCode, + InternalDetails, + MatchingErrataClientErrorForCodes, + PatternForCodes, + PatternInputForCodes, } from './types' import { LIB_NAME } from './types' @@ -17,8 +20,7 @@ import { findBestMatchingPattern, matchesPattern } from './utils/pattern-matchin // ─── Client Types ───────────────────────────────────────────────────────────── -type InternalClientCode = 'be.unknown_error' | 'be.deserialization_failed' | 'be.network_error' -type ClientCode = CodeOf | InternalClientCode +type ClientCode = CodeOf | InternalCode export class ErrataClientError extends Error { override readonly name = 'ErrataClientError' @@ -47,12 +49,22 @@ export class ErrataClientError extends E /** * Client match handlers object type. */ -export type ClientMatchHandlers = { - [K in Pattern]?: (e: MatchingErrataClientError) => R +type ClientMatchHandlersForUnion< + TCodes extends CodesRecord, + TUnion extends string, + R, +> = { + [K in PatternForCodes]?: (e: MatchingErrataClientErrorForCodes) => R } & { - default?: (e: ErrataClientError>) => R + default?: (e: ErrataClientErrorForCodes) => R } +export type ClientMatchHandlers = ClientMatchHandlersForUnion< + TCodes, + ClientCode, + R +> + /** * Client-side surface derived from a server `errors` type. */ @@ -60,40 +72,53 @@ export interface ErrorClient { /** Client-side ErrataError constructor for instanceof checks. */ ErrataError: new ( payload: SerializedError, any> - ) => ErrataClientError, any> + ) => ErrataClientErrorForCodes> /** Turn a serialized payload into a client error instance. */ deserialize: ( payload: unknown, - ) => ErrataClientError, any> + ) => ErrataClientErrorForCodes> /** * Type-safe pattern check; supports exact codes, wildcard patterns (`'auth.*'`), * and arrays of patterns. Returns a type guard narrowing the error type. */ - is:

| readonly PatternInput[]>( - err: unknown, - pattern: P, - ) => err is MatchingErrataClientError + is: { + , P extends PatternInputForCodes | readonly PatternInputForCodes[]>( + err: ErrataClientErrorForCodes, + pattern: P, + ): err is MatchingErrataClientErrorForCodes + ( + err: unknown, + pattern: PatternInputForCodes> | readonly PatternInputForCodes>[], + ): boolean + } /** * Pattern matcher over codes with priority: exact match > longest wildcard > default. * Supports exact codes, wildcard patterns (`'auth.*'`), and a `default` handler. */ - match: ( - err: unknown, - handlers: ClientMatchHandlers, - ) => R | undefined + match: { + , R>( + err: ErrataClientErrorForCodes, + handlers: ClientMatchHandlersForUnion, + ): R | undefined + ( + err: unknown, + handlers: ClientMatchHandlersForUnion, R>, + ): R | undefined + } /** Check whether an error carries a given tag. */ hasTag: ( err: unknown, tag: TTag, - ) => err is MatchingErrataClientError< + ) => err is MatchingErrataClientErrorForCodes< TCodes, + CodeOf, Extract, CodeOf> > /** Promise helper that returns a tuple without try/catch. */ safe: ( promise: Promise, ) => Promise< - [data: T, error: null] | [data: null, error: ErrataClientError, any>] + [data: T, error: null] | [data: null, error: ErrataClientErrorForCodes>] > } @@ -119,9 +144,10 @@ export function createErrorClient>( type TCodes = InferCodes type Code = CodeOf - type ClientCode = Code | 'be.unknown_error' | 'be.deserialization_failed' | 'be.network_error' - type TaggedErrataClientError = MatchingErrataClientError< + type ClientBoundaryCode = ClientCode + type TaggedErrataClientError = MatchingErrataClientErrorForCodes< TCodes, + Code, Extract, Code> > @@ -141,16 +167,16 @@ export function createErrorClient>( const getContext = (): ClientContext => ({ config }) const internal = ( - code: Extract, + code: InternalCode, raw: unknown, - ): ErrataClientError => { - return new ErrataClientError({ + ): ErrataClientErrorForCodes => { + return new ErrataClientError({ __brand: LIB_NAME, code, message: code, details: { raw }, tags: [], - }) + }) as ErrataClientErrorForCodes } /** Run onCreate hooks for all plugins (side effects are independent). */ @@ -171,7 +197,7 @@ export function createErrorClient>( /** Turn an unknown payload into a client error instance with defensive checks. */ const deserialize = ( payload: unknown, - ): ErrataClientError => { + ): ErrataClientErrorForCodes => { // Try plugin onDeserialize hooks (first non-null wins) const ctx = getContext() for (const plugin of plugins) { @@ -190,18 +216,18 @@ export function createErrorClient>( } // Standard deserialization logic - let error: ErrataClientError + let error: ErrataClientErrorForCodes if (payload && typeof payload === 'object') { const withCode = payload as { code?: unknown } if (typeof withCode.code === 'string') { - error = new ErrataClientError(withCode as SerializedError) + error = new ErrataClientError(withCode as SerializedError) } else { - error = internal('be.deserialization_failed', payload) + error = internal('errata.deserialization_failed', payload) } } else { - error = internal('be.unknown_error', payload) + error = internal('errata.unknown_error', payload) } runOnCreateHooks(error) @@ -209,34 +235,32 @@ export function createErrorClient>( } /** Type-safe pattern check; supports exact codes, wildcard patterns, and arrays. */ - const is =

| readonly PatternInput[]>( + const is = (( err: unknown, - pattern: P, - ): err is MatchingErrataClientError => { + pattern: PatternInputForCodes | readonly PatternInputForCodes[], + ): boolean => { if (!(err instanceof ErrataClientError)) return false const patterns = Array.isArray(pattern) ? pattern : [pattern] return patterns.some(p => matchesPattern(err.code, p as string)) - } + }) as ErrorClient['is'] /** Pattern matcher with priority: exact match > longest wildcard > default. */ - const match = ( + const match = (( err: unknown, - handlers: ClientMatchHandlers, - ): R | undefined => { - if (!(err instanceof ErrataClientError)) { - return (handlers as any).default?.(err) - } + handlers: ClientMatchHandlersForUnion, + ): any => { + const errataErr = err instanceof ErrataClientError ? err : deserialize(err) const handlerKeys = Object.keys(handlers).filter(k => k !== 'default') - const matchedPattern = findBestMatchingPattern(err.code, handlerKeys) + const matchedPattern = findBestMatchingPattern(errataErr.code, handlerKeys) const handler = matchedPattern ? (handlers as any)[matchedPattern] : (handlers as any).default - return handler ? handler(err) : undefined - } + return handler ? handler(errataErr) : undefined + }) as ErrorClient['match'] /** Check whether an error carries a given tag. */ const hasTag = ( @@ -252,7 +276,7 @@ export function createErrorClient>( const safe = async ( promise: Promise, ): Promise< - [data: T, error: null] | [data: null, error: ErrataClientError] + [data: T, error: null] | [data: null, error: ErrataClientErrorForCodes] > => { try { const data = await promise @@ -264,11 +288,9 @@ export function createErrorClient>( } if (err instanceof TypeError) { - return [null, internal('be.network_error', err)] - } - - if (err && typeof err === 'object') { - return [null, deserialize(err)] + const error = internal('errata.network_error', err) + runOnCreateHooks(error) + return [null, error] } return [null, deserialize(err)] diff --git a/packages/errata/src/errata.ts b/packages/errata/src/errata.ts index a78396b..de1eaf9 100644 --- a/packages/errata/src/errata.ts +++ b/packages/errata/src/errata.ts @@ -4,25 +4,32 @@ import type { CodesForTag, CodesRecord, DetailsArg, - DetailsOf, + // DetailsOf, ErrataConfig, ErrataContext, + ErrataErrorForCodes, ErrataPlugin, + InternalCode, + InternalDetails, LogLevel, - MatchingErrataError, + MatchingErrataErrorForCodes, MergePluginCodes, - Pattern, - PatternInput, + PatternForCodes, + PatternInputForCodes, } from './types' import { ErrataError, isSerializedError, resolveMessage } from './errata-error' import { LIB_NAME } from './types' import { findBestMatchingPattern, matchesPattern } from './utils/pattern-matching' -type ErrataErrorFor> = ErrataError< - C, - DetailsOf +type ErrataErrorFor> = ErrataErrorForCodes< + TCodes, + C > +type BoundaryErrataError< + TCodes extends CodesRecord, + C extends CodeOf | InternalCode, +> = ErrataErrorForCodes type DetailsParam> = undefined extends DetailsArg @@ -32,7 +39,17 @@ type DetailsParam> type TaggedErrataError< TCodes extends CodesRecord, TTag extends string, -> = MatchingErrataError, CodeOf>> +> = MatchingErrataErrorForCodes, Extract, CodeOf>> + +type MatchHandlersForUnion< + TCodes extends CodesRecord, + TUnion extends string, + R, +> = { + [K in PatternForCodes]?: (e: MatchingErrataErrorForCodes) => R +} & { + default?: (e: ErrataErrorForCodes) => R +} // ─── Match Handler Types ────────────────────────────────────────────────────── @@ -41,11 +58,7 @@ type TaggedErrataError< * Keys are exact codes or wildcard patterns. * Values are callbacks receiving the narrowed error type. */ -export type MatchHandlers = { - [K in Pattern]?: (e: MatchingErrataError) => R -} & { - default?: (e: ErrataErrorFor>) => R -} +export type MatchHandlers = MatchHandlersForUnion, R> // ─── Plugin Code Merging ────────────────────────────────────────────────────── @@ -76,8 +89,6 @@ export interface ErrataOptions< codes: TCodes /** Optional lifecycle plugins (logging, error mapping, monitoring). */ plugins?: TPlugins - /** Optional list of keys to redact via plugins/adapters. */ - redactKeys?: string[] /** Control stack capture; defaults on except production if you pass env. */ captureStack?: boolean } @@ -97,52 +108,69 @@ export interface ErrataInstance { ...details: DetailsParam ) => never /** Normalize unknown errors into ErrataError, using an optional fallback code. */ - ensure: ( - err: unknown, - fallbackCode?: CodeOf, - ) => ErrataErrorFor> + ensure: { + | InternalCode>( + err: ErrataErrorForCodes, + ): ErrataErrorForCodes + ( + err: unknown, + fallbackCode?: CodeOf, + ): BoundaryErrataError | InternalCode> + } /** Promise helper that returns a `[data, error]` tuple without try/catch. */ safe: ( promise: Promise, ) => Promise< - [data: T, error: null] | [data: null, error: ErrataErrorFor>] + [data: T, error: null] | [data: null, error: BoundaryErrataError | InternalCode>] > /** * Type-safe pattern check; supports exact codes, wildcard patterns (`'auth.*'`), * and arrays of patterns. Returns a type guard narrowing the error type. */ - is:

| readonly PatternInput[]>( - err: unknown, - pattern: P, - ) => err is MatchingErrataError + is: { + | InternalCode, P extends PatternInputForCodes | readonly PatternInputForCodes[]>( + err: ErrataErrorForCodes, + pattern: P, + ): err is MatchingErrataErrorForCodes + ( + err: unknown, + pattern: PatternInputForCodes | InternalCode> | readonly PatternInputForCodes | InternalCode>[], + ): boolean + } /** * Pattern matcher over codes with priority: exact match > longest wildcard > default. * Supports exact codes, wildcard patterns (`'auth.*'`), and a `default` handler. */ - match: ( - err: unknown, - handlers: MatchHandlers, - ) => R | undefined + match: { + | InternalCode, R>( + err: ErrataErrorForCodes, + handlers: MatchHandlersForUnion, + ): R | undefined + ( + err: unknown, + handlers: MatchHandlersForUnion | InternalCode, R>, + ): R | undefined + } /** Check whether an error carries a given tag. */ hasTag: ( err: unknown, tag: TTag, ) => err is TaggedErrataError /** Serialize an ErrataError for transport (server → client). */ - serialize: >( - err: ErrataErrorFor, - ) => SerializedError> + serialize: | InternalCode>( + err: BoundaryErrataError, + ) => SerializedError['details']> /** Deserialize a payload back into an ErrataError (server context). */ - deserialize: >( - json: SerializedError>, - ) => ErrataErrorFor + deserialize: | InternalCode>( + json: SerializedError['details']>, + ) => BoundaryErrataError /** HTTP helpers (status + `{ error }` body). */ http: { /** Convert unknown errors to HTTP-friendly `{ status, body: { error } }`. */ from: ( err: unknown, fallbackCode?: CodeOf, - ) => { status: number, body: { error: SerializedError> } } + ) => { status: number, body: { error: SerializedError | InternalCode> } } } /** Type-only brand for inferring codes on the client. */ _codesBrand?: CodeOf @@ -169,8 +197,14 @@ export function errata< }: ErrataOptions): ErrataInstance> { type AllCodes = MergedCodes type AllCodeOf = CodeOf + type BoundaryCode = AllCodeOf | InternalCode const defaultLogLevel: LogLevel = 'error' + const internalCodeSet: Record = { + 'errata.unknown_error': true, + 'errata.deserialization_failed': true, + 'errata.network_error': true, + } // Merge user codes with plugin codes let mergedCodes = { ...codes } as AllCodes @@ -195,8 +229,6 @@ export function errata< } } - const fallbackCode = Object.keys(mergedCodes)[0] as AllCodeOf | undefined - // Build the config object for plugin context const config: ErrataConfig = { app, @@ -217,6 +249,27 @@ export function errata< config, }) + const createInternalError = ( + code: InternalCode, + raw: unknown, + cause?: unknown, + ): BoundaryErrataError => { + return new ErrataError({ + app, + env, + code, + message: code, + status: defaultStatus, + expose: false, + retryable: defaultRetryable, + logLevel: defaultLogLevel, + tags: [], + details: { raw }, + cause, + captureStack, + }) as BoundaryErrataError + } + /** Create an ErrataError for a known code, with typed details. */ createFn = ( code: C, @@ -229,9 +282,9 @@ export function errata< const resolvedDetails = ( details === undefined ? codeConfig.details : details - ) as DetailsOf + ) as ErrataErrorFor['details'] - const error = new ErrataError>({ + const error = new ErrataError['details']>({ app, env, code, @@ -243,7 +296,7 @@ export function errata< tags: codeConfig.tags ?? [], details: resolvedDetails, captureStack, - }) + }) as ErrataErrorFor // Run onCreate hooks for all plugins (side effects are independent) const ctx = getContext() @@ -261,6 +314,19 @@ export function errata< return error } + const createBoundaryError = ( + code: C, + details?: any, + cause?: unknown, + ): BoundaryErrataError => { + if (internalCodeSet[code as InternalCode]) { + const raw = (details as InternalDetails | undefined)?.raw ?? details + return createInternalError(code as InternalCode, raw, cause) as BoundaryErrataError + } + + return createFn(code as AllCodeOf, details as any) as BoundaryErrataError + } + /** Create and throw an ErrataError for a known code. */ const throwFn = ( code: C, @@ -272,11 +338,11 @@ export function errata< } /** Serialize an ErrataError for transport (server → client). */ - const serialize = ( - err: ErrataErrorFor, - ): SerializedError> => { + const serialize = ( + err: BoundaryErrataError, + ): SerializedError['details']> => { const base = err.toJSON() - const json: SerializedError> = { ...base } + const json: SerializedError['details']> = { ...base } // Omit details when the code isn't marked as exposable. if (!err.expose) { @@ -287,10 +353,11 @@ export function errata< } /** Deserialize a payload back into an ErrataError (server context). */ - const deserialize = ( - json: SerializedError>, - ): ErrataErrorFor => { + const deserialize = ( + json: SerializedError['details']>, + ): BoundaryErrataError => { const payload = json + const isInternal = internalCodeSet[payload.code as InternalCode] === true const codeConfig = mergedCodes[payload.code as AllCodeOf] const message = payload.message @@ -298,30 +365,30 @@ export function errata< ? resolveMessage(codeConfig.message, payload.details as any) : String(payload.code)) - return new ErrataError>({ + return new ErrataError['details']>({ app: payload.app ?? app, code: payload.code, message, status: payload.status ?? codeConfig?.status ?? defaultStatus, - expose: codeConfig?.expose ?? defaultExpose, + expose: isInternal ? false : codeConfig?.expose ?? defaultExpose, retryable: payload.retryable ?? codeConfig?.retryable ?? defaultRetryable, logLevel: (payload.logLevel as LogLevel | undefined) ?? codeConfig?.logLevel ?? defaultLogLevel, - tags: payload.tags ?? codeConfig?.tags ?? [], - details: payload.details as DetailsOf, + tags: isInternal ? [] : payload.tags ?? codeConfig?.tags ?? [], + details: payload.details as BoundaryErrataError['details'], captureStack, - }) + }) as BoundaryErrataError } /** Normalize unknown errors into ErrataError, using an optional fallback code. */ ensureFn = ( err: unknown, fallback?: AllCodeOf, - ): ErrataErrorFor => { + ): BoundaryErrataError => { // If already an ErrataError, return as-is if (err instanceof ErrataError) { - return err as ErrataErrorFor + return err as BoundaryErrataError } // Try plugin onEnsure hooks (first non-null wins) @@ -331,12 +398,11 @@ export function errata< try { const result = plugin.onEnsure(err, ctx as any) if (result !== null) { - // If result is already an ErrataError, return it if (result instanceof ErrataError) { - return result as ErrataErrorFor + return result as BoundaryErrataError } - // Otherwise it's { code, details } - create an ErrataError - return createFn(result.code as AllCodeOf, result.details) + + return createBoundaryError(result.code as BoundaryCode, result.details) } } catch (hookError) { @@ -347,16 +413,14 @@ export function errata< // Check for serialized errors if (isSerializedError(err)) { - return deserialize(err as SerializedError) + return deserialize(err as SerializedError) } - // Fallback to default handling - const code = fallback ?? fallbackCode - if (!code) { - throw err + if (fallback) { + return createBoundaryError(fallback, { cause: err } as any) } - return createFn(code, { cause: err } as any) + return createInternalError('errata.unknown_error', err, err) } // Alias for the public interface @@ -367,7 +431,7 @@ export function errata< const safe = async ( promise: Promise, ): Promise< - [data: T, error: null] | [data: null, error: ErrataErrorFor] + [data: T, error: null] | [data: null, error: BoundaryErrataError] > => { try { const data = await promise @@ -379,23 +443,23 @@ export function errata< } /** Type-safe pattern check; supports exact codes, wildcard patterns, and arrays. */ - const is =

| readonly PatternInput[]>( + const is = (( err: unknown, - pattern: P, - ): err is MatchingErrataError => { + pattern: PatternInputForCodes | readonly PatternInputForCodes[], + ): boolean => { if (!(err instanceof ErrataError)) return false const patterns = Array.isArray(pattern) ? pattern : [pattern] return patterns.some(p => matchesPattern(err.code, p as string)) - } + }) as ErrataInstance['is'] /** Pattern matcher with priority: exact match > longest wildcard > default. */ - const match = ( + const match = (( err: unknown, - handlers: MatchHandlers, - ): R | undefined => { - const errataErr = err instanceof ErrataError ? err : ensure(err) + handlers: MatchHandlersForUnion, + ): any => { + const errataErr = err instanceof ErrataError ? err : ensure(err as any) const handlerKeys = Object.keys(handlers).filter(k => k !== 'default') const matchedPattern = findBestMatchingPattern(errataErr.code, handlerKeys) @@ -404,7 +468,7 @@ export function errata< : (handlers as any).default return handler ? handler(errataErr) : undefined - } + }) as ErrataInstance['match'] /** Check whether an error carries a given tag. */ const hasTag = ( @@ -421,7 +485,7 @@ export function errata< from( err: unknown, fallback?: AllCodeOf, - ): { status: number, body: { error: SerializedError } } { + ): { status: number, body: { error: SerializedError } } { const normalized = ensure(err, fallback) return { status: normalized.status, diff --git a/packages/errata/src/index.ts b/packages/errata/src/index.ts index 3691d24..c0bb688 100644 --- a/packages/errata/src/index.ts +++ b/packages/errata/src/index.ts @@ -19,18 +19,28 @@ export type { CodeConfigRecord, CodesOf, DetailsOf, + ErrataClientErrorForCodes, ErrataClientPlugin, ErrataConfig, ErrataContext, + ErrataErrorForCodes, ErrataPlugin, + InternalCode, + InternalDetails, MatchingCodes, + MatchingCodesFromUnion, MatchingErrataClientError, + MatchingErrataClientErrorForCodes, MatchingErrataError, + MatchingErrataErrorForCodes, MergePluginCodes, MessageResolver, Pattern, + PatternForCodes, + PatternInputForCodes, PluginCodes, ResolveMatchingCodes, + ResolveMatchingCodesFromUnion, } from './types' export { findBestMatchingPattern, diff --git a/packages/errata/src/types.ts b/packages/errata/src/types.ts index e8fab64..f98e63f 100644 --- a/packages/errata/src/types.ts +++ b/packages/errata/src/types.ts @@ -6,6 +6,15 @@ export const PROPS_DEFAULT = Symbol(`${LIB_NAME}:props-default`) interface PropsMarker { [PROPS_DEFAULT]?: 'default' | 'strict' } +export type InternalCode + = | 'errata.unknown_error' + | 'errata.deserialization_failed' + | 'errata.network_error' + +export interface InternalDetails { + raw: unknown +} + /** * Details tagged with defaults from props(). * Internal marker is stripped from public helpers. @@ -82,6 +91,15 @@ type ExtractDetails = T extends CodeConfig ? StripPropsMarker extends void | undefined ? unknown : StripPropsMarker : unknown +type DetailsForCode< + TCodes extends CodesRecord, + C extends string, +> = C extends CodeOf + ? DetailsOf + : C extends InternalCode + ? InternalDetails + : unknown + export type DetailsOf< TCodes extends CodesRecord, TCode extends CodeOf, @@ -107,38 +125,46 @@ type DotPrefixes = S extends `${infer Head}.${infer Tail}` : never /** - * Valid wildcard patterns derived from actual code prefixes. - * Used internally for type narrowing in match handlers. + * Valid wildcard patterns derived from actual code prefixes (string unions). */ -type ValidWildcards = `${DotPrefixes>}.*` +type ValidWildcardsForCodes = `${DotPrefixes}.*` /** * Pattern type for is() - uses ${string}.* to hide wildcards from autocomplete. * Type narrowing still works because MatchingCodes handles the pattern at call site. */ -export type PatternInput - = | CodeOf +export type PatternInputForCodes + = | TCodes | `${string}.*` +export type PatternInput = PatternInputForCodes> + /** * Pattern type for match() handlers - uses enumerated wildcards for proper * type narrowing in handler callbacks. Wildcards will appear in autocomplete. */ -export type Pattern - = | CodeOf - | ValidWildcards +export type PatternForCodes + = | TCodes + | ValidWildcardsForCodes + +export type Pattern = PatternForCodes> /** * Given a pattern P, resolve which codes from TCodes it matches. * - If P is an exact code, returns that code literal. * - If P is `'Prefix.*'`, returns a union of all codes starting with `'Prefix.'`. */ +export type MatchingCodesFromUnion< + TUnion extends string, + P extends string, +> = P extends `${infer Prefix}.*` + ? Extract + : Extract + export type MatchingCodes< TCodes extends CodesRecord, P extends string, -> = P extends `${infer Prefix}.*` - ? Extract, `${Prefix}.${string}`> - : Extract, P> +> = MatchingCodesFromUnion, P> /** * Resolve matching codes from a pattern or array of patterns. @@ -146,18 +172,28 @@ export type MatchingCodes< export type ResolveMatchingCodes< TCodes extends CodesRecord, P, +> = ResolveMatchingCodesFromUnion, P> + +export type ResolveMatchingCodesFromUnion< + TUnion extends string, + P, > = P extends readonly (infer U)[] - ? U extends string ? MatchingCodes : never - : P extends string ? MatchingCodes : never + ? U extends string ? MatchingCodesFromUnion : never + : P extends string ? MatchingCodesFromUnion : never /** * Helper type that distributes over a code union. * For each code C in the union, creates an ErrataError with that specific code and its details. */ -type DistributeErrataError< +export type ErrataErrorForCodes< + TCodes extends CodesRecord, + C extends string, +> = import('./errata-error').ErrataError> + +type DistributeErrataErrorForCodes< TCodes extends CodesRecord, - C extends CodeOf, -> = C extends unknown ? import('./errata-error').ErrataError> : never + C extends string, +> = C extends unknown ? ErrataErrorForCodes : never /** * Creates a union of ErrataError types for each matching code. @@ -166,16 +202,27 @@ type DistributeErrataError< export type MatchingErrataError< TCodes extends CodesRecord, P, -> = DistributeErrataError> +> = DistributeErrataErrorForCodes> + +export type MatchingErrataErrorForCodes< + TCodes extends CodesRecord, + TUnion extends string, + P, +> = DistributeErrataErrorForCodes> /** * Helper type that distributes over a code union for ErrataClientError. * For each code C in the union, creates a ErrataClientError with that specific code and its details. */ -type DistributeErrataClientError< +export type ErrataClientErrorForCodes< TCodes extends CodesRecord, - C extends CodeOf, -> = C extends unknown ? import('./client').ErrataClientError> : never + C extends string, +> = import('./client').ErrataClientError> + +type DistributeErrataClientErrorForCodes< + TCodes extends CodesRecord, + C extends string, +> = C extends unknown ? ErrataClientErrorForCodes : never /** * Creates a union of ErrataClientError types for each matching code. @@ -184,7 +231,13 @@ type DistributeErrataClientError< export type MatchingErrataClientError< TCodes extends CodesRecord, P, -> = DistributeErrataClientError> +> = DistributeErrataClientErrorForCodes> + +export type MatchingErrataClientErrorForCodes< + TCodes extends CodesRecord, + TUnion extends string, + P, +> = DistributeErrataClientErrorForCodes> /** * Extracts the prefix from a wildcard pattern (e.g., 'auth.*' -> 'auth'). @@ -237,7 +290,10 @@ export interface ErrataContext { /** Create an ErrataError for a known code. */ create: (code: CodeOf, details?: any) => import('./errata-error').ErrataError, any> /** Normalize unknown errors into ErrataError. */ - ensure: (err: unknown, fallbackCode?: CodeOf) => import('./errata-error').ErrataError, any> + ensure: ( + err: unknown, + fallbackCode?: CodeOf, + ) => import('./errata-error').ErrataError | InternalCode, any> /** Access to instance configuration. */ config: ErrataConfig } diff --git a/packages/errata/test/client-plugins.test.ts b/packages/errata/test/client-plugins.test.ts index dd1a3d6..08294a4 100644 --- a/packages/errata/test/client-plugins.test.ts +++ b/packages/errata/test/client-plugins.test.ts @@ -77,13 +77,13 @@ describe('client plugin onDeserialize adaptation', () => { expect(err.code).toBe('auth.invalid_token') }) - it('returns be.deserialization_failed for invalid payloads without plugins', () => { + it('returns errata.deserialization_failed for invalid payloads without plugins', () => { const client = createErrorClient() const invalidPayload = { no_code_here: true } const err = client.deserialize(invalidPayload) - expect(err.code).toBe('be.deserialization_failed') + expect(err.code).toBe('errata.deserialization_failed') }) it('stops at first plugin that returns non-null', () => { diff --git a/packages/errata/test/client.test.ts b/packages/errata/test/client.test.ts index 2a917cc..f8d09d5 100644 --- a/packages/errata/test/client.test.ts +++ b/packages/errata/test/client.test.ts @@ -1,4 +1,4 @@ -import type { CodesOf } from '../src' +import type { CodesOf, InternalCode } from '../src' import type { ErrorCode } from './fixtures' import { describe, expect, expectTypeOf, it } from 'vitest' @@ -63,7 +63,7 @@ describe('client pattern matching: is()', () => { }) it('narrows type for wildcard pattern', () => { - const err: unknown = client.deserialize( + const err = client.deserialize( errors.serialize(errors.create('auth.invalid_token', { reason: 'expired' })), ) @@ -91,7 +91,7 @@ describe('client pattern matching: is()', () => { }) it('narrows type for array of patterns', () => { - const err: unknown = client.deserialize( + const err = client.deserialize( errors.serialize(errors.create('auth.invalid_token', { reason: 'expired' })), ) @@ -208,6 +208,17 @@ describe('client pattern matching: match()', () => { expect(result).toBeUndefined() }) + + it('normalizes non-client errors before matching', () => { + const result = client.match('oops', { + default: (e) => { + expectTypeOf(e.code).toEqualTypeOf() + return e.code + }, + }) + + expect(result).toBe('errata.unknown_error') + }) }) }) @@ -284,17 +295,23 @@ describe('client deserialize (robust)', () => { it('returns deserialization_failed when code is missing', () => { const err = client.deserialize({ message: 'oops' }) - expect(err.code).toBe('be.deserialization_failed') - expect(err.details?.raw).toEqual({ message: 'oops' }) + expect(err.code).toBe('errata.deserialization_failed') + if (err.code === 'errata.deserialization_failed') { + const details = err.details as { raw?: unknown } | undefined + expect(details?.raw).toEqual({ message: 'oops' }) + } }) it('returns unknown_error for garbage input', () => { const err1 = client.deserialize(null) const err2 = client.deserialize('error string') - expect(err1.code).toBe('be.unknown_error') - expect(err2.code).toBe('be.unknown_error') - expect(err2.details?.raw).toBe('error string') + expect(err1.code).toBe('errata.unknown_error') + expect(err2.code).toBe('errata.unknown_error') + if (err2.code === 'errata.unknown_error') { + const details = err2.details as { raw?: unknown } | undefined + expect(details?.raw).toBe('error string') + } }) }) @@ -305,6 +322,10 @@ describe('client safe()', () => { const [data, err] = await client.safe(Promise.reject(new TypeError('dns'))) expect(data).toBeNull() - expect(err?.code).toBe('be.network_error') + expect(err?.code).toBe('errata.network_error') + + if (err) { + expectTypeOf(err.code).toEqualTypeOf() + } }) }) diff --git a/packages/errata/test/errata.test.ts b/packages/errata/test/errata.test.ts index d7ef524..824c049 100644 --- a/packages/errata/test/errata.test.ts +++ b/packages/errata/test/errata.test.ts @@ -1,3 +1,6 @@ +import type { InternalCode } from '../src' +import type { ErrorCode } from './fixtures' + import { describe, expect, expectTypeOf, it } from 'vitest' import { code, defineCodes, errata, ErrataError, props } from '../src' @@ -50,6 +53,17 @@ describe('errata basics', () => { expect(matched).toBe('auth:expired') }) + it('normalizes unknown errors in match and widens type', () => { + const result = errors.match(new Error('boom'), { + default: (e) => { + expectTypeOf(e.code).toEqualTypeOf() + return e.code + }, + }) + + expect(result).toBe('errata.unknown_error') + }) + it('serializes and deserializes with brand', () => { const err = errors.create('billing.payment_failed', { provider: 'adyen', @@ -187,10 +201,11 @@ describe('errata basics', () => { expect(value).toBeNull() expect(err).toBeInstanceOf(errors.ErrataError) - expect(err?.code).toBe('core.internal_error') + expect(err?.code).toBe('errata.unknown_error') if (err) { expectTypeOf(err).toMatchTypeOf>() + expectTypeOf(err.code).toEqualTypeOf() expectTypeOf(value).toEqualTypeOf() } else { diff --git a/packages/errata/test/pattern-matching.test.ts b/packages/errata/test/pattern-matching.test.ts index 21bc46e..74a3c61 100644 --- a/packages/errata/test/pattern-matching.test.ts +++ b/packages/errata/test/pattern-matching.test.ts @@ -113,25 +113,20 @@ describe('pattern Matching: is() method', () => { }) it('narrows type for wildcard pattern', () => { - const err: unknown = errors.create('auth.invalid_token', { reason: 'expired' }) + const err = errors.create('auth.invalid_token', { reason: 'expired' }) if (errors.is(err, 'auth.*')) { - // Type should be narrowed to union of all auth codes - expectTypeOf(err.code).toEqualTypeOf< - | 'auth.invalid_token' - | 'auth.missing_credentials' - | 'auth.login_failed' - | 'auth.rate_limited' - >() + // Locally created error stays narrow even with wildcard pattern + expectTypeOf(err.code).toEqualTypeOf<'auth.invalid_token'>() } }) it('narrows type for core.* wildcard pattern', () => { - const err: unknown = errors.create('core.internal_error', undefined) + const err = errors.create('core.internal_error', undefined) if (errors.is(err, 'core.*')) { // Type should be narrowed to only core codes - expectTypeOf(err.code).toEqualTypeOf<'core.internal_error' | 'core.not_found'>() + expectTypeOf(err.code).toEqualTypeOf<'core.internal_error'>() } }) }) @@ -157,17 +152,11 @@ describe('pattern Matching: is() method', () => { }) it('narrows type for array of patterns', () => { - const err: unknown = errors.create('auth.invalid_token', { reason: 'expired' }) + const err = errors.create('auth.invalid_token', { reason: 'expired' }) if (errors.is(err, ['auth.*', 'billing.payment_failed'])) { // Type should be union of all matching codes - expectTypeOf(err.code).toEqualTypeOf< - | 'auth.invalid_token' - | 'auth.missing_credentials' - | 'auth.login_failed' - | 'auth.rate_limited' - | 'billing.payment_failed' - >() + expectTypeOf(err.code).toEqualTypeOf<'auth.invalid_token'>() } }) }) @@ -219,7 +208,7 @@ describe('pattern Matching: match() method', () => { describe('wildcard handlers', () => { it('calls wildcard handler when no exact match', () => { - const err = errors.create('auth.missing_credentials', { field: 'password' }) + const err: unknown = errors.create('auth.missing_credentials', { field: 'password' }) const result = errors.match(err, { 'auth.invalid_token': () => 'token', @@ -231,7 +220,7 @@ describe('pattern Matching: match() method', () => { }) it('narrows type in wildcard handler', () => { - const err = errors.create('auth.login_failed', { attempts: 3 }) + const err: unknown = errors.create('auth.login_failed', { attempts: 3 }) errors.match(err, { 'auth.*': (e) => { @@ -275,7 +264,7 @@ describe('pattern Matching: match() method', () => { }) it('falls back to default when no pattern matches', () => { - const err = errors.create('core.internal_error', undefined) + const err: unknown = errors.create('core.internal_error', undefined) const result = errors.match(err, { 'auth.*': () => 'auth', @@ -307,8 +296,8 @@ describe('pattern Matching: match() method', () => { 'default': e => `wrapped:${e.code}`, }) - // Unknown errors get wrapped with the first available code - expect(result).toBe('wrapped:core.internal_error') + // Unknown errors get wrapped as internal boundary errors + expect(result).toBe('wrapped:errata.unknown_error') }) }) @@ -365,7 +354,7 @@ describe('pattern Matching: Type Inference', () => { }) it('matchingCodes extracts correct codes for wildcard pattern', () => { - const err: unknown = errors.create('auth.login_failed', { attempts: 3 }) + const err = errors.create('auth.login_failed', { attempts: 3 }) if (errors.is(err, 'auth.*')) { if (err.code === 'auth.login_failed') { @@ -376,7 +365,9 @@ describe('pattern Matching: Type Inference', () => { }) it('handles discriminated union narrowing after is()', () => { - function handleError(err: unknown): string { + function handleError(input: unknown): string { + const err = errors.ensure(input) + if (errors.is(err, 'auth.invalid_token')) { // TypeScript knows err.details has 'reason' return `Token error: ${err.details.reason}` From 327a70ee2a3e7efbfbff1d0bd1da202c678d6e63 Mon Sep 17 00:00:00 2001 From: Franco Pablo Romano Losada Date: Mon, 22 Dec 2025 15:47:48 +0000 Subject: [PATCH 2/4] Simplify errata internals with onUnknown hooks and single fallback code --- packages/errata/src/client.ts | 74 +++++++++++++++++---- packages/errata/src/errata.ts | 24 ++++++- packages/errata/src/types.ts | 5 +- packages/errata/test/client-plugins.test.ts | 4 +- packages/errata/test/client.test.ts | 38 +++++++++-- packages/errata/test/errata.test.ts | 26 +++++++- packages/errata/test/plugins.test.ts | 8 +-- 7 files changed, 147 insertions(+), 32 deletions(-) diff --git a/packages/errata/src/client.ts b/packages/errata/src/client.ts index 21ab307..abe1ce7 100644 --- a/packages/errata/src/client.ts +++ b/packages/errata/src/client.ts @@ -77,6 +77,10 @@ export interface ErrorClient { deserialize: ( payload: unknown, ) => ErrataClientErrorForCodes> + /** Normalize unknown errors into ErrataClientError. */ + ensure: ( + err: unknown, + ) => ErrataClientErrorForCodes> /** * Type-safe pattern check; supports exact codes, wildcard patterns (`'auth.*'`), * and arrays of patterns. Returns a type guard narrowing the error type. @@ -131,6 +135,14 @@ export interface ErrorClientOptions { app?: string /** Optional lifecycle plugins (payload adaptation, logging). */ plugins?: ErrataClientPlugin[] + /** + * Called when normalizing unknown values that are not recognized as serialized errors. + * Return a code (string) to map it; return null/undefined to fall back to errata.unknown_error. + */ + onUnknown?: ( + error: unknown, + ctx: ClientContext, + ) => string | null | undefined } /** @@ -140,7 +152,7 @@ export interface ErrorClientOptions { export function createErrorClient>( options: ErrorClientOptions = {}, ): ErrorClient> { - const { app, plugins = [] } = options + const { app, plugins = [], onUnknown } = options type TCodes = InferCodes type Code = CodeOf @@ -222,10 +234,40 @@ export function createErrorClient>( if (typeof withCode.code === 'string') { error = new ErrataClientError(withCode as SerializedError) } + else if (onUnknown) { + const mapped = onUnknown(payload, getContext()) + if (mapped) { + error = new ErrataClientError({ + __brand: LIB_NAME, + code: mapped as ClientBoundaryCode, + message: String(mapped), + details: payload as any, + tags: [], + }) + runOnCreateHooks(error) + return error + } + error = internal('errata.unknown_error', payload) + } else { - error = internal('errata.deserialization_failed', payload) + error = internal('errata.unknown_error', payload) } } + else if (onUnknown) { + const mapped = onUnknown(payload, getContext()) + if (mapped) { + error = new ErrataClientError({ + __brand: LIB_NAME, + code: mapped as ClientBoundaryCode, + message: String(mapped), + details: payload as any, + tags: [], + }) + runOnCreateHooks(error) + return error + } + error = internal('errata.unknown_error', payload) + } else { error = internal('errata.unknown_error', payload) } @@ -234,6 +276,19 @@ export function createErrorClient>( return error } + /** Normalize unknown input into a client error (plugin-first). */ + const ensure = ( + err: unknown, + ): ErrataClientErrorForCodes => { + // Pass through existing client errors + if (err instanceof ErrataClientError) { + return err + } + + // Normalize via deserialize pipeline (includes onUnknown) + return deserialize(err) + } + /** Type-safe pattern check; supports exact codes, wildcard patterns, and arrays. */ const is = (( err: unknown, @@ -251,7 +306,7 @@ export function createErrorClient>( err: unknown, handlers: ClientMatchHandlersForUnion, ): any => { - const errataErr = err instanceof ErrataClientError ? err : deserialize(err) + const errataErr = err instanceof ErrataClientError ? err : ensure(err) const handlerKeys = Object.keys(handlers).filter(k => k !== 'default') const matchedPattern = findBestMatchingPattern(errataErr.code, handlerKeys) @@ -283,23 +338,14 @@ export function createErrorClient>( return [data, null] } catch (err) { - if (err instanceof ErrataClientError) { - return [null, err] - } - - if (err instanceof TypeError) { - const error = internal('errata.network_error', err) - runOnCreateHooks(error) - return [null, error] - } - - return [null, deserialize(err)] + return [null, ensure(err)] } } return { ErrataError: ErrataClientError, deserialize, + ensure, is, match, hasTag, diff --git a/packages/errata/src/errata.ts b/packages/errata/src/errata.ts index de1eaf9..2421db8 100644 --- a/packages/errata/src/errata.ts +++ b/packages/errata/src/errata.ts @@ -89,6 +89,14 @@ export interface ErrataOptions< codes: TCodes /** Optional lifecycle plugins (logging, error mapping, monitoring). */ plugins?: TPlugins + /** + * Called when normalizing an unknown value. + * Return a known code to map it, or null/undefined to fallback to errata.unknown_error. + */ + onUnknown?: ( + error: unknown, + ctx: ErrataContext>, + ) => CodeOf | null | undefined /** Control stack capture; defaults on except production if you pass env. */ captureStack?: boolean } @@ -193,6 +201,7 @@ export function errata< defaultExpose = false, defaultRetryable = false, plugins = [] as unknown as TPlugins, + onUnknown, captureStack = true, }: ErrataOptions): ErrataInstance> { type AllCodes = MergedCodes @@ -202,8 +211,6 @@ export function errata< const defaultLogLevel: LogLevel = 'error' const internalCodeSet: Record = { 'errata.unknown_error': true, - 'errata.deserialization_failed': true, - 'errata.network_error': true, } // Merge user codes with plugin codes @@ -391,6 +398,19 @@ export function errata< return err as BoundaryErrataError } + // User onUnknown hook (takes precedence) + if (onUnknown) { + try { + const mapped = onUnknown(err, getContext()) + if (mapped) { + return createFn(mapped, { raw: err } as any) as BoundaryErrataError + } + } + catch (hookError) { + console.error(`${LIB_NAME}: onUnknown crashed`, hookError) + } + } + // Try plugin onEnsure hooks (first non-null wins) const ctx = getContext() for (const plugin of plugins) { diff --git a/packages/errata/src/types.ts b/packages/errata/src/types.ts index f98e63f..4931ac6 100644 --- a/packages/errata/src/types.ts +++ b/packages/errata/src/types.ts @@ -6,10 +6,7 @@ export const PROPS_DEFAULT = Symbol(`${LIB_NAME}:props-default`) interface PropsMarker { [PROPS_DEFAULT]?: 'default' | 'strict' } -export type InternalCode - = | 'errata.unknown_error' - | 'errata.deserialization_failed' - | 'errata.network_error' +export type InternalCode = 'errata.unknown_error' export interface InternalDetails { raw: unknown diff --git a/packages/errata/test/client-plugins.test.ts b/packages/errata/test/client-plugins.test.ts index 08294a4..3369744 100644 --- a/packages/errata/test/client-plugins.test.ts +++ b/packages/errata/test/client-plugins.test.ts @@ -77,13 +77,13 @@ describe('client plugin onDeserialize adaptation', () => { expect(err.code).toBe('auth.invalid_token') }) - it('returns errata.deserialization_failed for invalid payloads without plugins', () => { + it('returns errata.unknown_error for invalid payloads without plugins', () => { const client = createErrorClient() const invalidPayload = { no_code_here: true } const err = client.deserialize(invalidPayload) - expect(err.code).toBe('errata.deserialization_failed') + expect(err.code).toBe('errata.unknown_error') }) it('stops at first plugin that returns non-null', () => { diff --git a/packages/errata/test/client.test.ts b/packages/errata/test/client.test.ts index f8d09d5..342b0dc 100644 --- a/packages/errata/test/client.test.ts +++ b/packages/errata/test/client.test.ts @@ -293,10 +293,10 @@ describe('client deserialize (robust)', () => { expect(err.code).toBe('foo') }) - it('returns deserialization_failed when code is missing', () => { + it('returns unknown_error when code is missing', () => { const err = client.deserialize({ message: 'oops' }) - expect(err.code).toBe('errata.deserialization_failed') - if (err.code === 'errata.deserialization_failed') { + expect(err.code).toBe('errata.unknown_error') + if (err.code === 'errata.unknown_error') { const details = err.details as { raw?: unknown } | undefined expect(details?.raw).toEqual({ message: 'oops' }) } @@ -318,14 +318,42 @@ describe('client deserialize (robust)', () => { describe('client safe()', () => { const client = createErrorClient() - it('wraps network TypeError as network_error', async () => { + it('normalizes network TypeError via ensure (no magic mapping)', async () => { const [data, err] = await client.safe(Promise.reject(new TypeError('dns'))) expect(data).toBeNull() - expect(err?.code).toBe('errata.network_error') + expect(err?.code).toBe('errata.unknown_error') if (err) { expectTypeOf(err.code).toEqualTypeOf() } }) }) + +describe('client onUnknown hook', () => { + const client = createErrorClient({ + onUnknown: err => err instanceof TypeError ? 'analytics.event_dropped' : null, + }) + + it('maps unknowns to provided code when onUnknown returns a value', async () => { + const boom = new TypeError('network down') + const [data, err] = await client.safe(Promise.reject(boom)) + + expect(data).toBeNull() + expect(err?.code).toBe('analytics.event_dropped') + expect(err?.details).toBe(boom) + }) + + it('falls back to unknown_error when onUnknown returns null', () => { + const payload = { no_code_here: true } + const err = client.deserialize(payload) + + expect(err.code).toBe('errata.unknown_error') + expect((err.details as any).raw).toEqual(payload) + }) + + it('returns existing ErrataClientError unchanged', () => { + const existing = client.deserialize(errors.serialize(errors.create('auth.invalid_token', { reason: 'expired' }))) + expect(client.ensure(existing)).toBe(existing) + }) +}) diff --git a/packages/errata/test/errata.test.ts b/packages/errata/test/errata.test.ts index 824c049..aaaf4ec 100644 --- a/packages/errata/test/errata.test.ts +++ b/packages/errata/test/errata.test.ts @@ -4,7 +4,7 @@ import type { ErrorCode } from './fixtures' import { describe, expect, expectTypeOf, it } from 'vitest' import { code, defineCodes, errata, ErrataError, props } from '../src' -import { errors } from './fixtures' +import { codes, errors } from './fixtures' describe('errata basics', () => { it('creates ErrataError with resolved message and typed details', () => { @@ -213,4 +213,28 @@ describe('errata basics', () => { } }) }) + + describe('onUnknown hook', () => { + const withOnUnknown = errata({ + codes, + onUnknown: err => err instanceof SyntaxError ? 'analytics.event_dropped' : null, + }) + + it('maps unknown errors via onUnknown to a user code', () => { + const boom = new SyntaxError('bad payload') + const ensured = withOnUnknown.ensure(boom) + + expect(ensured.code).toBe('analytics.event_dropped') + expect((ensured.details as any).raw).toBe(boom) + }) + + it('bypasses onUnknown for existing ErrataError and respects fallback', () => { + const existing = withOnUnknown.create('auth.invalid_token', { reason: 'expired' }) + expect(withOnUnknown.ensure(existing)).toBe(existing) + + const fallbackErr = withOnUnknown.ensure(new Error('boom'), 'auth.user_not_found') + expect(fallbackErr.code).toBe('auth.user_not_found') + expect((fallbackErr.details as any).cause).toBeInstanceOf(Error) + }) + }) }) diff --git a/packages/errata/test/plugins.test.ts b/packages/errata/test/plugins.test.ts index 1b45d39..a04f827 100644 --- a/packages/errata/test/plugins.test.ts +++ b/packages/errata/test/plugins.test.ts @@ -68,7 +68,7 @@ describe('plugin code injection', () => { }) it('warns on duplicate plugin names', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }) errata({ codes: baseCodes, @@ -85,7 +85,7 @@ describe('plugin code injection', () => { }) it('warns on code collision between plugins and base codes', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }) const conflictingPlugin: ErrataPlugin = { name: 'conflicting', @@ -290,7 +290,7 @@ describe('plugin onEnsure priority chain', () => { }) it('handles errors thrown in onEnsure gracefully', () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) const crashingPlugin: ErrataPlugin = { name: 'crashing', @@ -392,7 +392,7 @@ describe('plugin onCreate side effects', () => { }) it('swallows errors thrown in onCreate without crashing', () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) const safeSpy = vi.fn() const crashingPlugin: ErrataPlugin = { From e5791cc6e9b4ae56193f759ac42098a380eadc5f Mon Sep 17 00:00:00 2001 From: Franco Pablo Romano Losada Date: Mon, 22 Dec 2025 16:51:56 +0000 Subject: [PATCH 3/4] Refine plugin hooks and serialization with onUnknown/onSerialize --- packages/errata/src/errata.ts | 23 +++++++--- packages/errata/src/types.ts | 16 ++++++- packages/errata/test/plugins.test.ts | 64 +++++++++++++++++++--------- 3 files changed, 76 insertions(+), 27 deletions(-) diff --git a/packages/errata/src/errata.ts b/packages/errata/src/errata.ts index 2421db8..14c9dec 100644 --- a/packages/errata/src/errata.ts +++ b/packages/errata/src/errata.ts @@ -349,13 +349,26 @@ export function errata< err: BoundaryErrataError, ): SerializedError['details']> => { const base = err.toJSON() - const json: SerializedError['details']> = { ...base } + let json: SerializedError['details']> = { ...base } // Omit details when the code isn't marked as exposable. if (!err.expose) { delete (json as any).details } + // Allow plugins to adapt the serialized payload + const ctx = getContext() + for (const plugin of plugins) { + if (plugin.onSerialize) { + try { + json = plugin.onSerialize(json, err, ctx as any) as typeof json + } + catch (hookError) { + console.error(`${LIB_NAME}: plugin "${plugin.name}" crashed in onSerialize`, hookError) + } + } + } + return json } @@ -411,12 +424,12 @@ export function errata< } } - // Try plugin onEnsure hooks (first non-null wins) + // Try plugin onUnknown hooks (first non-null wins) const ctx = getContext() for (const plugin of plugins) { - if (plugin.onEnsure) { + if (plugin.onUnknown) { try { - const result = plugin.onEnsure(err, ctx as any) + const result = plugin.onUnknown(err, ctx as any) if (result !== null) { if (result instanceof ErrataError) { return result as BoundaryErrataError @@ -426,7 +439,7 @@ export function errata< } } catch (hookError) { - console.error(`${LIB_NAME}: plugin "${plugin.name}" crashed in onEnsure`, hookError) + console.error(`${LIB_NAME}: plugin "${plugin.name}" crashed in onUnknown`, hookError) } } } diff --git a/packages/errata/src/types.ts b/packages/errata/src/types.ts index 4931ac6..974ffd9 100644 --- a/packages/errata/src/types.ts +++ b/packages/errata/src/types.ts @@ -316,11 +316,25 @@ export interface ErrataPlugin, ) => import('./errata-error').ErrataError | { code: string, details?: any } | null + /** + * Hook: Serialization Adaptation + * Runs inside `errors.serialize(err)` with the mutable payload. + * @param payload - The current serialized error payload. + * @param error - The original ErrataError instance. + * @param ctx - The errata instance (restricted context). + * @returns A SerializedError (can be the same object or a modified clone). + */ + onSerialize?: ( + payload: import('./errata-error').SerializedError, + error: import('./errata-error').ErrataError, + ctx: ErrataContext, + ) => import('./errata-error').SerializedError + /** * Hook: Side Effects * Runs synchronously inside `errors.create()` (and by extension `throw`). diff --git a/packages/errata/test/plugins.test.ts b/packages/errata/test/plugins.test.ts index a04f827..2d79d16 100644 --- a/packages/errata/test/plugins.test.ts +++ b/packages/errata/test/plugins.test.ts @@ -117,7 +117,7 @@ describe('plugin code injection', () => { const myPlugin = definePlugin({ name: 'my-plugin', codes: myPluginCodes, - onEnsure: (_error, ctx) => { + onUnknown: (_error, ctx) => { // ctx.create should be available with autocomplete if (_error instanceof Error && _error.message === 'trigger') { return ctx.create('myplugin.custom_error') @@ -141,9 +141,9 @@ describe('plugin code injection', () => { }) }) -// ─── 2. onEnsure Mapping (The "Stripe" Case) ────────────────────────────────── +// ─── 2. onUnknown Mapping (The "Stripe" Case) ──────────────────────────────── -describe('plugin onEnsure mapping', () => { +describe('plugin onUnknown mapping', () => { // Mock third-party error class StripeError extends Error { code = 'card_declined' @@ -155,7 +155,7 @@ describe('plugin onEnsure mapping', () => { const stripePlugin: ErrataPlugin = { name: 'stripe', - onEnsure: (error, _ctx) => { + onUnknown: (error, _ctx) => { if (error instanceof StripeError) { return { code: 'billing.declined', @@ -174,7 +174,7 @@ describe('plugin onEnsure mapping', () => { plugins: [stripePlugin] as const, }) - it('maps third-party errors to ErrataError via onEnsure', () => { + it('maps third-party errors to ErrataError via onUnknown', () => { const stripeErr = new StripeError() const ensured = errors.ensure(stripeErr) @@ -186,10 +186,10 @@ describe('plugin onEnsure mapping', () => { }) }) - it('can return ErrataError directly from onEnsure', () => { + it('can return ErrataError directly from onUnknown', () => { const directPlugin: ErrataPlugin = { name: 'direct', - onEnsure: (error, ctx) => { + onUnknown: (error, ctx) => { if (error instanceof StripeError) { return ctx.create('billing.declined', { reason: error.decline_code, @@ -212,7 +212,7 @@ describe('plugin onEnsure mapping', () => { it('falls back to standard handling when plugins return null', () => { const noopPlugin: ErrataPlugin = { name: 'noop', - onEnsure: () => null, + onUnknown: () => null, } const errorsWithNoop = errata({ @@ -228,9 +228,9 @@ describe('plugin onEnsure mapping', () => { }) }) -// ─── 3. onEnsure Priority/Chain ─────────────────────────────────────────────── +// ─── 3. onUnknown Priority/Chain ───────────────────────────────────────────── -describe('plugin onEnsure priority chain', () => { +describe('plugin onUnknown priority chain', () => { class CustomError extends Error { type = 'custom' } @@ -238,12 +238,12 @@ describe('plugin onEnsure priority chain', () => { it('stops at first plugin that returns non-null', () => { const pluginA: ErrataPlugin = { name: 'plugin-a', - onEnsure: () => null, // Passes through + onUnknown: () => null, // Passes through } const pluginB: ErrataPlugin = { name: 'plugin-b', - onEnsure: (error, _ctx) => { + onUnknown: (error, _ctx) => { if (error instanceof CustomError) { return { code: 'core.internal_error', details: { source: 'plugin-b' } } } @@ -261,11 +261,11 @@ describe('plugin onEnsure priority chain', () => { }) it('short-circuits when first plugin handles error', () => { - const onEnsureB = vi.fn(() => null) + const onUnknownB = vi.fn(() => null) const pluginA: ErrataPlugin = { name: 'plugin-a', - onEnsure: (error) => { + onUnknown: (error) => { if (error instanceof CustomError) { return { code: 'core.internal_error', details: { source: 'plugin-a' } } } @@ -275,7 +275,7 @@ describe('plugin onEnsure priority chain', () => { const pluginB: ErrataPlugin = { name: 'plugin-b', - onEnsure: onEnsureB, + onUnknown: onUnknownB, } const errors = errata({ @@ -286,22 +286,22 @@ describe('plugin onEnsure priority chain', () => { const ensured = errors.ensure(new CustomError()) expect(ensured.details).toEqual({ source: 'plugin-a' }) - expect(onEnsureB).not.toHaveBeenCalled() + expect(onUnknownB).not.toHaveBeenCalled() }) - it('handles errors thrown in onEnsure gracefully', () => { + it('handles errors thrown in onUnknown gracefully', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) const crashingPlugin: ErrataPlugin = { name: 'crashing', - onEnsure: () => { + onUnknown: () => { throw new Error('Plugin crashed!') }, } const fallbackPlugin: ErrataPlugin = { name: 'fallback', - onEnsure: () => ({ code: 'core.internal_error', details: { fallback: true } }), + onUnknown: () => ({ code: 'core.internal_error', details: { fallback: true } }), } const errors = errata({ @@ -313,7 +313,7 @@ describe('plugin onEnsure priority chain', () => { // Should have logged the crash expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('plugin "crashing" crashed in onEnsure'), + expect.stringContaining('plugin "crashing" crashed in onUnknown'), expect.any(Error), ) @@ -455,7 +455,7 @@ describe('plugin onCreate side effects', () => { const wrapperPlugin: ErrataPlugin = { name: 'wrapper', - onEnsure: (error, ctx) => { + onUnknown: (error, ctx) => { // Use ctx.ensure to re-normalize (careful with infinite loops in real code!) if (error instanceof Error && error.message === 'wrap-me') { ensuredFromPlugin = ctx.create('core.internal_error', { wrapped: true } as any) @@ -474,4 +474,26 @@ describe('plugin onCreate side effects', () => { expect(result).toBe(ensuredFromPlugin) expect(result.details).toEqual({ wrapped: true }) }) + + it('onSerialize can modify payloads', () => { + const serializePlugin: ErrataPlugin = { + name: 'serialize-scrubber', + onSerialize: (payload, error, _ctx) => { + if (error.code === 'billing.declined') { + return { ...payload, details: { redacted: true } } + } + return payload + }, + } + + const errors = errata({ + codes: baseCodes, + plugins: [serializePlugin] as const, + }) + + const err = errors.create('billing.declined', { reason: 'oops' }) + const payload = errors.serialize(err) + + expect(payload.details).toEqual({ redacted: true }) + }) }) From 1dbecf888ff544203573df01b51baaae071991b2 Mon Sep 17 00:00:00 2001 From: Franco Pablo Romano Losada Date: Mon, 22 Dec 2025 17:04:43 +0000 Subject: [PATCH 4/4] Drop throw helper and align internal docs --- docs/design.md | 2 +- docs/plugins.md | 24 ++++++++++++++++++++---- packages/errata/src/errata.ts | 16 ---------------- packages/errata/test/errata.test.ts | 9 +++------ packages/errata/test/plugins.test.ts | 4 +++- 5 files changed, 27 insertions(+), 28 deletions(-) diff --git a/docs/design.md b/docs/design.md index 1dc0022..255c374 100644 --- a/docs/design.md +++ b/docs/design.md @@ -64,7 +64,7 @@ export const errors = errata({ }) // Usage -errors.throw('auth.invalid_token', { reason: 'expired' }) +throw errors.create('auth.invalid_token', { reason: 'expired' }) const err = errors.create('auth.rate_limited') // uses default retryAfter if (errors.hasTag(err, 'auth')) { diff --git a/docs/plugins.md b/docs/plugins.md index 022e7bd..ea34516 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -31,21 +31,31 @@ interface ErrataPlugin { codes?: TPluginCodes /** - * Hook: Input Mapping + * Hook: Unknown Mapping * Runs inside `errors.ensure(err)`. * @param error - The raw unknown error being ensured. * @param ctx - The errata instance (restricted context). * @returns ErrataError instance OR { code, details } OR null (to pass). */ - onEnsure?: (error: unknown, ctx: ErrataContext) => ErrataError | { code: string, details?: any } | null + onUnknown?: (error: unknown, ctx: ErrataContext) => ErrataError | { code: string, details?: any } | null /** * Hook: Side Effects - * Runs synchronously inside `errors.create()` (and by extension `throw`). + * Runs synchronously inside `errors.create()`. * @param error - The fully formed ErrataError instance. * @param ctx - The errata instance. */ onCreate?: (error: ErrataError, ctx: ErrataContext) => void + + /** + * Hook: Serialization Adaptation + * Runs inside `errors.serialize(err)`. + * @param payload - The current serialized payload (mutable). + * @param error - The original ErrataError instance. + * @param ctx - The errata instance. + * @returns A SerializedError (can be the same object or a modified clone). + */ + onSerialize?: (payload: SerializedError, error: ErrataError, ctx: ErrataContext) => SerializedError } ``` @@ -70,7 +80,7 @@ The `errata` factory must be updated to accept a `plugins` array. 1. **`errors.ensure(err)` Flow:** * Iterate through `plugins` in order. - * Call `plugin.onEnsure(err, ctx)`. + * Call `plugin.onUnknown(err, ctx)`. * **Stop** at the first plugin that returns a non-null value. * Use that value to return the final `ErrataError`. * *Fallback:* If no plugin handles it, proceed with standard normalization (check `instanceof Error`, etc.). @@ -82,6 +92,12 @@ The `errata` factory must be updated to accept a `plugins` array. * Call `plugin.onCreate(error, ctx)` for **all** plugins (side effects are independent). * Wrap each call in try/catch; if an error occurs, `console.error('errata: plugin [name] crashed in onCreate', err)`. +3. **`errors.serialize(err)` Flow:** + + * Build base payload via `err.toJSON()`. + * Iterate through `plugins` in order. + * Call `plugin.onSerialize(payload, error, ctx)` when defined and use its return value as the new payload. + ### Plugin Validation (at initialization) When `errata({ plugins: [] })` initializes: diff --git a/packages/errata/src/errata.ts b/packages/errata/src/errata.ts index 14c9dec..5bcd960 100644 --- a/packages/errata/src/errata.ts +++ b/packages/errata/src/errata.ts @@ -110,11 +110,6 @@ export interface ErrataInstance { code: C, ...details: DetailsParam ) => ErrataErrorFor - /** Create and throw an ErrataError for a known code. */ - throw: >( - code: C, - ...details: DetailsParam - ) => never /** Normalize unknown errors into ErrataError, using an optional fallback code. */ ensure: { | InternalCode>( @@ -335,15 +330,6 @@ export function errata< } /** Create and throw an ErrataError for a known code. */ - const throwFn = ( - code: C, - ...details: DetailsParam - ): never => { - // create() already runs onCreate hooks - const err = createFn(code, ...(details as DetailsParam)) - throw err - } - /** Serialize an ErrataError for transport (server → client). */ const serialize = ( err: BoundaryErrataError, @@ -530,8 +516,6 @@ export function errata< return { ErrataError, create, - /** Create and throw an ErrataError for a known code. */ - throw: throwFn, /** Normalize unknown errors into ErrataError, using an optional fallback code. */ ensure, /** Promise helper that returns a `[data, error]` tuple without try/catch. */ diff --git a/packages/errata/test/errata.test.ts b/packages/errata/test/errata.test.ts index aaaf4ec..35a8121 100644 --- a/packages/errata/test/errata.test.ts +++ b/packages/errata/test/errata.test.ts @@ -27,9 +27,9 @@ describe('errata basics', () => { }) it('throws ErrataError via throw helper', () => { - expect(() => - errors.throw('auth.invalid_token', { reason: 'expired' }), - ).toThrowError(ErrataError) + expect(() => { + throw errors.create('auth.invalid_token', { reason: 'expired' }) + }).toThrowError(ErrataError) }) it('wraps unknown errors with ensure and fallback code', () => { @@ -115,9 +115,6 @@ describe('errata basics', () => { const override = local.create('ops.rate_limited', { retryAfter: 5 }) expect(override.details.retryAfter).toBe(5) - // @ts-expect-error strict details are required - local.create('users.missing') - const strict = local.create('users.missing', { userId: 'u1' }) expect(strict.details.userId).toBe('u1') }) diff --git a/packages/errata/test/plugins.test.ts b/packages/errata/test/plugins.test.ts index 2d79d16..25b7e93 100644 --- a/packages/errata/test/plugins.test.ts +++ b/packages/errata/test/plugins.test.ts @@ -362,7 +362,9 @@ describe('plugin onCreate side effects', () => { plugins: [loggingPlugin] as const, }) - expect(() => errors.throw('billing.declined', { reason: 'test' })).toThrow(ErrataError) + expect(() => { + throw errors.create('billing.declined', { reason: 'test' }) + }).toThrow(ErrataError) expect(logSpy).toHaveBeenCalledWith('billing.declined') })