autotel
Version:
Write Once, Observe Anywhere
263 lines (229 loc) • 8.52 kB
text/typescript
/**
* 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>;
}