UNPKG

autotel

Version:
263 lines (229 loc) 8.52 kB
/** * Typed error and audit catalogs. * * Group related errors into one catalog and get a refactor-safe builder per * code, with autocomplete at every call site and typed message parameters. * * @example * ```typescript * import { defineErrorCatalog } from 'autotel'; * * export const billing = defineErrorCatalog('billing', { * PAYMENT_DECLINED: { * status: 402, * message: 'Card declined', * why: 'The issuer rejected the charge', * fix: 'Try a different payment method', * }, * INSUFFICIENT_FUNDS: { * status: 402, * message: ({ available, required }: { available: number; required: number }) => * `Insufficient funds: $${available} of $${required}`, * }, * }); * * throw billing.PAYMENT_DECLINED({ cause: stripeError }); * throw billing.INSUFFICIENT_FUNDS({ available: 5, required: 100 }); * * // In a catch block — refactor-safe, no magic strings: * if (billing.PAYMENT_DECLINED.match(err)) { ... } * ``` */ import { createStructuredError, type StructuredError, } from './structured-error'; const catalogCodeKey = Symbol.for('autotel.catalog.code'); /** Definition of a single error in a catalog. */ export interface ErrorCatalogEntry { /** * Human-readable message. Use a function to interpolate typed parameters; * the parameter type flows through to the call site. */ message: string | ((params: never) => string); /** HTTP status to surface to clients. */ status?: number; /** Stable error code. Defaults to `${namespace}.${KEY}`. */ code?: string | number; /** Why it happened. A function receives the same params as `message`. */ why?: string | ((params: never) => string); /** What the caller should do next. */ fix?: string; /** Docs or runbook link. */ link?: string; /** Error name. Defaults to the catalog key. */ name?: string; } /** Per-call options passed alongside (or instead of) typed params. */ export interface ErrorBuildOptions { cause?: unknown; details?: Record<string, unknown>; /** Backend-only context. Never serialized to clients. */ internal?: Record<string, unknown>; } type ParamsOf<E> = E extends { message: (params: infer P) => string } ? P : E extends { why: (params: infer P) => string } ? P : void; type BuilderArgs<E extends ErrorCatalogEntry> = ParamsOf<E> extends void ? [options?: ErrorBuildOptions] : [params: ParamsOf<E>, options?: ErrorBuildOptions]; /** A callable error factory produced by {@link defineErrorCatalog}. */ export interface ErrorBuilder<E extends ErrorCatalogEntry> { (...args: BuilderArgs<E>): StructuredError; /** Stable code assigned to every error from this entry. */ readonly code: string | number; /** True when `error` was produced by this catalog entry. */ match(error: unknown): boolean; } export type ErrorCatalog<T extends Record<string, ErrorCatalogEntry>> = { readonly [K in keyof T]: ErrorBuilder<T[K]>; }; function readCatalogCode(error: unknown): string | number | undefined { if (error === null || typeof error !== 'object') return undefined; return (error as Record<symbol, unknown>)[catalogCodeKey] as | string | number | undefined; } /** True when `error` was produced by any autotel error catalog. */ export function isCatalogError(error: unknown): error is StructuredError { return readCatalogCode(error) !== undefined; } /** Returns the catalog code of `error`, or `undefined` if it has none. */ export function getCatalogCode(error: unknown): string | number | undefined { return readCatalogCode(error); } /** * Define a typed error catalog. Returns an object whose keys are error * builders. Each builder produces a {@link StructuredError} carrying the * entry's message, status, code, why, fix, and link. */ export function defineErrorCatalog< const T extends Record<string, ErrorCatalogEntry>, >(namespace: string, entries: T): ErrorCatalog<T> { const catalog: Record<string, ErrorBuilder<ErrorCatalogEntry>> = {}; for (const [key, entry] of Object.entries(entries) as [ string, ErrorCatalogEntry, ][]) { const code = entry.code ?? `${namespace}.${key}`; const usesParams = typeof entry.message === 'function' || typeof entry.why === 'function'; const builder = (( paramsOrOptions?: unknown, maybeOptions?: ErrorBuildOptions, ): StructuredError => { const params = usesParams ? paramsOrOptions : undefined; const options = (usesParams ? maybeOptions : paramsOrOptions) as | ErrorBuildOptions | undefined; const message = typeof entry.message === 'function' ? (entry.message as (p: unknown) => string)(params) : entry.message; const why = typeof entry.why === 'function' ? (entry.why as (p: unknown) => string)(params) : entry.why; const error = createStructuredError({ message, name: entry.name ?? key, code, ...(entry.status === undefined ? {} : { status: entry.status }), ...(why === undefined ? {} : { why }), ...(entry.fix === undefined ? {} : { fix: entry.fix }), ...(entry.link === undefined ? {} : { link: entry.link }), ...(options?.cause === undefined ? {} : { cause: options.cause }), ...(options?.details === undefined ? {} : { details: options.details }), ...(options?.internal === undefined ? {} : { internal: options.internal }), }); Object.defineProperty(error, catalogCodeKey, { value: code, enumerable: false, writable: false, configurable: true, }); return error; }) as ErrorBuilder<ErrorCatalogEntry>; Object.defineProperty(builder, 'code', { value: code, enumerable: true, }); Object.defineProperty(builder, 'match', { value: (error: unknown): boolean => readCatalogCode(error) === code, enumerable: false, }); catalog[key] = builder; } return Object.freeze(catalog) as ErrorCatalog<T>; } /** Severity of an audit action. */ export type AuditSeverity = 'info' | 'warn' | 'critical'; /** Definition of a single action in an audit catalog. */ export interface AuditCatalogEntry { /** Human-readable description. Use a function for typed params. */ message?: string | ((params: never) => string); /** Stable action name. Defaults to `${namespace}.${KEY}`. */ action?: string; /** Severity of the action. Defaults to `'info'`. */ severity?: AuditSeverity; } /** A resolved audit action descriptor produced by an audit catalog. */ export interface AuditAction { readonly action: string; readonly severity: AuditSeverity; readonly message?: string; } type AuditDescriptorArgs<E extends AuditCatalogEntry> = ParamsOf<E> extends void ? [] : [params: ParamsOf<E>]; /** A callable audit-action descriptor produced by {@link defineAuditCatalog}. */ export interface AuditDescriptor<E extends AuditCatalogEntry> { (...args: AuditDescriptorArgs<E>): AuditAction; readonly action: string; readonly severity: AuditSeverity; } export type AuditCatalog<T extends Record<string, AuditCatalogEntry>> = { readonly [K in keyof T]: AuditDescriptor<T[K]>; }; /** * Define a typed audit catalog. Returns typed action descriptors you can pass * to `track()` or audit helpers without scattering magic strings. */ export function defineAuditCatalog< const T extends Record<string, AuditCatalogEntry>, >(namespace: string, entries: T): AuditCatalog<T> { const catalog: Record<string, AuditDescriptor<AuditCatalogEntry>> = {}; for (const [key, entry] of Object.entries(entries) as [ string, AuditCatalogEntry, ][]) { const action = entry.action ?? `${namespace}.${key}`; const severity: AuditSeverity = entry.severity ?? 'info'; const descriptor = ((params?: unknown): AuditAction => { const message = typeof entry.message === 'function' ? (entry.message as (p: unknown) => string)(params) : entry.message; return Object.freeze({ action, severity, ...(message === undefined ? {} : { message }), }); }) as AuditDescriptor<AuditCatalogEntry>; Object.defineProperty(descriptor, 'action', { value: action, enumerable: true, }); Object.defineProperty(descriptor, 'severity', { value: severity, enumerable: true, }); catalog[key] = descriptor; } return Object.freeze(catalog) as AuditCatalog<T>; }