UNPKG

@shirudo/base-error

Version:

A cross-environment base error class for TypeScript applications, designed for seamless use across Node.js, browsers, and edge runtimes.

456 lines (446 loc) 23.5 kB
/** * Options for {@link LocalizedMessageSet}. */ type LocalizedMessageSetOptions = { /** * The guaranteed-fallback locale, made explicit (no hidden default). After * canonicalization it must have an entry in `messages`. */ readonly baseLocale: string; /** * Locale tag to message text. Keys are canonicalized (BCP 47); message * contents are preserved verbatim and never trimmed or modified. */ readonly messages: Readonly<Record<string, string>>; }; /** * An immutable, canonicalized set of localized messages keyed by BCP 47 locale. * * Construction enforces the write-side invariants: every key is canonicalized * with `Intl.getCanonicalLocales` (invalid tags throw), keys that collide after * canonicalization throw, every message must contain at least one non-whitespace * character, and an entry for the (canonical) `baseLocale` must exist. Lookups * are exact, canonical matches with no parent fallback: walking up a tag * (`de-DE` to `de`) and choosing between preferences is the resolver's job, not * the set's. On the read side an invalid requested tag is a miss, never a throw. */ declare class LocalizedMessageSet { #private; /** Canonical BCP 47 tag of the guaranteed-fallback locale. */ readonly baseLocale: string; constructor(options: LocalizedMessageSetOptions); /** * Whether an exact (canonical) entry exists for `locale`. No parent fallback. * An invalid tag is a miss. */ has(locale: string): boolean; /** * The exact (canonical) message for `locale`, or `undefined`. No parent * fallback. An invalid tag yields `undefined`. */ get(locale: string): string | undefined; /** * Fast-path lookup for a key that is **already canonical**. Skips * canonicalization, so the caller is responsible for passing a canonical tag * (a non-canonical spelling misses). Used by the resolver, which already * canonicalizes once and walks canonical truncation tags; prefer {@link get} * for untrusted input. */ getCanonical(canonicalLocale: string): string | undefined; /** A copy of the entries as `[canonicalLocale, message]` pairs. */ entries(): ReadonlyArray<readonly [string, string]>; } /** * The outcome of resolving a localized message: the text plus the locale that * actually matched and how. `matchedPreferenceIndex` and `match` are diagnostic * (tests, finding missing translations) and need not reach a public view. */ type ResolvedUserMessage = { /** Canonical BCP 47 tag that actually matched. */ locale: string; /** The message for that locale. */ message: string; /** * Index into the supplied `locales` whose tag (or one of its parents) * matched. `undefined` only when the match came from the appended baseLocale. */ matchedPreferenceIndex?: number; /** * `exact` when the canonical supplied tag matched, `parent` when one of its * truncations matched, `base` only when the appended baseLocale matched. */ match: "exact" | "parent" | "base"; }; /** * Resolves a single localized message from `set` against an ordered list of * locale preferences (RFC 4647 lookup). For each supplied locale, in order, the * canonical tag and its truncation chain are tried; the first present entry * wins. Candidates are deduped preserving first-seen order, so a duplicate is * attributed to the earlier preference's chain. The baseLocale is consulted only * after every supplied preference, and a match against it alone is reported as * `base`. An invalid supplied tag is skipped (a miss), never a throw. * * This never returns `undefined`: a {@link LocalizedMessageSet} always has an * entry for its baseLocale, which is the guaranteed floor. */ declare function resolveUserMessage(set: LocalizedMessageSet, options?: { locales?: readonly string[]; }): ResolvedUserMessage; /** * A single, vetted field-level fault. The common validation case made * first-class so it need not be smuggled through ad-hoc extensions. `field` is a * client-meaningful path, `code` a stable, localizable reason (never a raw * message). RFC 9457 does not define this member; it is a documented extension * (`fields`) the transport adapter writes by default. */ type FieldFault = { readonly field: string; readonly code: string; }; /** * The single source of truth for one public error code. One registration feeds * all three stages: curation ({@link project}), localization ({@link localize}), * and transport ({@link toProblem}). There is no second adapter map. * * Curation is a security boundary: nothing of the internal error reaches a view * automatically. `category`/`retryable` here are the *public* values, declared * by the registrant, deliberately distinct from the technical * `StructuredError.category`/`retryable` (which may reveal infrastructure, e.g. * a `DEADLOCK`). The internal taxonomy is never the wire taxonomy, exactly as * `publicCode` is distinct from the internal `code`. * * `userMessages` is optional: an app that localizes entirely on the client omits * it and still gets a machine-complete view and problem body. */ type PublicErrorDescriptor<TError = unknown, TDetails = never, TPublicCode extends string = string> = { /** Stable public code for the wire. */ readonly publicCode: TPublicCode; /** Transport status (HTTP / RFC 9457). Read only by {@link toProblem}. */ readonly status: number; /** Optional RFC 9457 problem type URI (ideally dereferences to docs). */ readonly type?: string; /** * Optional static, developer-facing summary of the problem type (RFC 9457 * `title`). Audience is the API consumer reading the JSON, not the end user; * stable per code, not localized. Distinct from {@link userMessages}, which is * the localized end-user text. `toProblem` emits a localized message as the * title when present, otherwise this static one. */ readonly title?: string; /** * Curated public category. Never the internal `StructuredError.category`. An * advisory coarse grouping for telemetry and soft UX, NOT an exhaustive branch * key: branch on `publicCode`, which is the typed contract. Declare * `categories` on the catalog to enforce a closed vocabulary and catch drift. */ readonly category?: string; /** Declared retryability hint; overridable per occurrence by {@link projectRetryable}. */ readonly retryable?: boolean; /** Optional client-safe localized messages. Omit for client-side i18n. */ readonly userMessages?: LocalizedMessageSet; /** * Explicit projection of a vetted, typed subset onto `details`. Never * spreads the error. Return **fresh** data (a new object built from vetted * values), not a reference into the error: `details` is deliberately not * cloned at this stage (the in-process view may hold rich values; `toProblem` * is the wire boundary), so a returned internal reference couples the view to * internal error state and later mutation reaches it. */ readonly projectDetails?: (error: TError) => TDetails; /** Optional per-occurrence retryability, falling back to {@link retryable} if it throws. */ readonly projectRetryable?: (error: TError) => boolean; /** * Optional per-occurrence retry delay in whole seconds, read from the error * (e.g. a rate limiter's window). Surfaced as the view's `retryAfter` and, by * `toProblem`, as the HTTP `Retry-After` header. A non-integer/negative result * or a throw is ignored. */ readonly projectRetryAfter?: (error: TError) => number | undefined; /** * Optional projection of vetted field faults. Validation's common path. * The result is normalized into a frozen copy of exactly `{ field, code }` * per fault: foreign extra properties are stripped and the view is decoupled * from the returned objects. */ readonly projectFields?: (error: TError) => readonly FieldFault[]; }; /** * The curated, transport-neutral, message-free machine view. Carries public * meaning only: a `publicCode`, optional curated `category`/`retryable`, and * explicitly projected `details`/`fields`. No status (transport's job), no * message (localization's job). The output of {@link project}, total over * `unknown`. */ type PublicError<TDetails = unknown, TCode extends string = string> = { readonly code: TCode; /** Advisory coarse grouping (see the descriptor). Branch on `code`, not this. */ readonly category?: string; readonly retryable?: boolean; /** Neutral retry-delay hint in whole seconds (e.g. for a 429/503 occurrence). */ readonly retryAfter?: number; readonly details?: TDetails; readonly fields?: readonly FieldFault[]; }; /** A {@link PublicError} after the optional {@link localize} stage attached human text. */ type LocalizedPublicError<TDetails = unknown, TCode extends string = string> = PublicError<TDetails, TCode> & { readonly message: string; readonly locale: string; }; /** * How the `details`/`fields` projection went: `none` when the descriptor has no * projector, `succeeded` when one ran cleanly, `failed` when one threw (the view * still stands without that member). Surfaced for debugging a silently missing * `details`. */ type ProjectionStatus = "none" | "succeeded" | "failed"; /** * What a `project` did, for fire-and-forget observability: whether the error * matched a descriptor (and how) or fell back, plus the projection status. A * fallback caused by a throwing matcher is `matcher_failed`, distinct from a * genuine `no_match`. */ type ProjectionOutcome = { readonly kind: "matched"; readonly via: "code" | "predicate"; readonly projection: ProjectionStatus; } | { readonly kind: "fallback"; readonly reason: "no_match" | "matcher_failed"; readonly projection: ProjectionStatus; }; /** * Fire-and-forget observer invoked once per {@link project}. The central place * to log the technical error alongside the emitted public code and outcome. If * it throws, the projector swallows it: telemetry must never break totality. */ type OnProject = (error: unknown, view: PublicError, outcome: ProjectionOutcome) => void; /** A descriptor with its types erased, as stored and returned by the catalog. */ type AnyDescriptor = PublicErrorDescriptor<unknown, unknown>; /** * The static wire metadata of a public code, as read by {@link toProblem}: * status, the RFC 9457 type, and the static developer-facing title. */ type Transport = { readonly status: number; readonly type?: string; /** Static, developer-facing problem-type summary (RFC 9457 `title`). */ readonly title?: string; }; /** * The outcome of resolving an error against the catalog. `matcherThrew` lets a * caller distinguish a genuine miss from a broken matcher, mirroring the * presentation registry. */ type CatalogResolution = { readonly found: true; readonly via: "code" | "predicate"; readonly descriptor: AnyDescriptor; readonly matcherThrew: boolean; } | { readonly found: false; readonly matcherThrew: boolean; }; /** * The single source of truth: one descriptor per public error code, addressable * both by the internal error `code`/predicate (for {@link project}) and by * public code (for {@link toProblem}'s transport facet and {@link localize}'s * messages). Unifies what used to be a registry (messages/details) plus a * separate adapter map (status/type), so the two cannot drift. * * Resolution order matches the presentation registry: exact internal `code`, * then predicate matchers in registration order, then a miss (the `fallback`). */ declare class PublicErrorCatalog<TPublicCode extends string = string> { #private; /** The generic descriptor used for any unmatched error. */ readonly fallback: AnyDescriptor; constructor(options: { fallback: PublicErrorDescriptor<never, never, TPublicCode>; /** Fire-and-forget observer invoked once per {@link project} through this catalog. */ onProject?: OnProject; /** * Optional closed vocabulary for the advisory public `category`. When given, * every descriptor's `category` must be a member, validated at registration * to prevent drift. `category` remains advisory: branch on `publicCode`. */ categories?: readonly string[]; }); /** * Invokes the configured {@link OnProject} observer, swallowing any error so * telemetry can never break projection totality. Called by `project`. */ observeProjection(error: unknown, view: PublicError, outcome: ProjectionOutcome): void; /** * Registers a descriptor keyed by an exact internal error `code`. Returns a * catalog widened with the new public code, so a chain of registrations * accumulates the public-code union for end-to-end typing. */ registerByCode<TError = unknown, TDetails = never, const TNewCode extends string = string>(code: string, descriptor: PublicErrorDescriptor<TError, TDetails, TNewCode>): PublicErrorCatalog<TPublicCode | TNewCode>; /** Registers a descriptor guarded by a type-guard matcher, tried after code matches. */ register<TError, TDetails = never, const TNewCode extends string = string>(entry: { match: (error: unknown) => error is TError; descriptor: PublicErrorDescriptor<TError, TDetails, TNewCode>; }): PublicErrorCatalog<TPublicCode | TNewCode>; /** Resolves the descriptor for `error`, or a miss. */ resolve(error: unknown): CatalogResolution; /** * The static wire metadata (status/type/title) for a registered public code, * or `undefined` if the code is not registered (including the fallback, which * is indexed at construction). An unknown code is a foreign/stale view that * must not be paired with this catalog's fallback status; the caller decides. */ transportFor(publicCode: string): Transport | undefined; /** The localized messages registered for a public code, if any. */ messagesFor(publicCode: string): LocalizedMessageSet | undefined; /** * Asserts that every code in `knownCodes` has a `registerByCode` descriptor. * An opt-in completeness check for the consumer's composition root. */ assertCoverage(knownCodes: readonly string[]): void; } /** The union of public codes a {@link PublicErrorCatalog} can produce. */ type PublicCodeOf<TCatalog> = TCatalog extends PublicErrorCatalog<infer TPublicCode> ? TPublicCode : never; /** * Builds a catalog whose public-code union is inferred from the fallback (and * grows as you chain `registerByCode`/`register`). Prefer this over `new` when * you want the UI to switch exhaustively on `code` at compile time; `new` * leaves the union as the open `string`. */ declare function definePublicErrors<const TCode extends string>(options: { fallback: PublicErrorDescriptor<never, never, TCode>; onProject?: OnProject; categories?: readonly string[]; }): PublicErrorCatalog<TCode>; /** * Stage 1: curation as a security boundary. Turns an unknown technical error * into a curated, transport-neutral, message-free {@link PublicError}. Total * over `unknown`: an unmatched error degrades to the catalog's fallback rather * than leaking or throwing. Nothing of the error reaches the view automatically; * only declared (`category`/`retryable`) or explicitly projected * (`details`/`fields`) values appear, and a throwing projector is contained. * * Invokes the catalog's {@link OnProject} observer once, so a single place can * log the technical error alongside the emitted code and outcome. */ declare function project<TPublicCode extends string>(catalog: PublicErrorCatalog<TPublicCode>, error: unknown): PublicError<unknown, TPublicCode>; /** * Catalog-free projection against a single descriptor the caller already chose. * The same curation rules as {@link project}, without resolution or the * observer: use this when you bring your own matching, or have no catalog. */ declare function projectWithDescriptor<TCode extends string>(descriptor: PublicErrorDescriptor<never, unknown, TCode>, error: unknown): PublicError<unknown, TCode>; /** * Stage 2: localization, deliberately optional and orthogonal. Attaches human * text to a {@link PublicError}, resolving `messages` against ordered locale * preferences. The `messages` set is keyed on the public code by the caller: a * backend passes `catalog.messagesFor(view.code)`, a client passes its own * catalog for the same public code. A client-localizing app simply never calls * this stage and renders text from `view.code` itself. */ declare function localize<TDetails, TCode extends string = string>(view: PublicError<TDetails, TCode>, messages: LocalizedMessageSet, options?: { locales?: readonly string[]; }): LocalizedPublicError<TDetails, TCode>; /** * Shared JSON-safety helper. A value that crosses a wire (an HTTP body, an RPC * boundary, `postMessage`) must survive `JSON.stringify` losslessly and must not * carry a hostile prototype. This module is the single clone-and-freeze * implementation for the public-error transport stage (`toProblem`), so the * wire-safety guarantee lives at exactly one place. */ /** The subset of values that round-trips through JSON without loss. */ type JsonSafeValue = null | boolean | number | string | readonly JsonSafeValue[] | { readonly [key: string]: JsonSafeValue; }; /** * Shared RFC 9457 / HTTP helpers. One definition of "a valid problem status", "a * usable type/title string", and the problem media type, reused across the * public-error pipeline (projection, catalog registration, and the transport * stage) so the rules cannot drift between them. */ /** Media type for RFC 9457 JSON problem details. */ declare const PROBLEM_DETAILS_JSON: "application/problem+json"; /** A dynamic body member dropped because it was not JSON-safe. */ type OmittedMember = "details" | "fields" | "extensions"; /** Body members the adapter owns; an extension may not collide with them. */ declare const RESERVED_BODY_FIELDS: readonly ["type", "title", "status", "detail", "instance", "code", "category", "retryable", "retryAfter", "fields", "details"]; type ReservedBodyField = (typeof RESERVED_BODY_FIELDS)[number]; /** Every extension value must be JSON-safe; a non-JSON-safe field is `never`. */ type JsonSafeExtensionShape<TExtensions extends object> = { readonly [K in keyof TExtensions]: Pick<TExtensions, K> extends Required<Pick<TExtensions, K>> ? TExtensions[K] extends JsonSafeValue ? TExtensions[K] : never : Exclude<TExtensions[K], undefined> extends JsonSafeValue ? TExtensions[K] : never; }; /** Extensions must be string-keyed (symbol/number keys are rejected). */ type StringKeyedExtensionShape<TExtensions extends object> = Exclude<keyof TExtensions, string> extends never ? unknown : never; /** * Per-occurrence members added while mapping one view to a problem. `extensions` * are additional top-level body members; they are compile-time constrained to be * JSON-safe, string-keyed, and free of reserved field names, and re-validated at * runtime (a non-JSON-safe or colliding set drops to `outcome.omitted`). */ type ToProblemContext<TExtensions extends object = Record<never, never>> = { /** RFC 9457 occurrence URI. */ readonly instance?: string; /** RFC 9457 occurrence-specific explanation (distinct from the per-type title). */ readonly detail?: string; /** * Retry delay in whole seconds, overriding the view's `retryAfter`. For a * boundary that knows the value (a rate limiter) rather than the error. A * non-integer/negative value is ignored. */ readonly retryAfter?: number; /** Additional JSON-safe top-level body members, keyed by a non-reserved name. */ readonly extensions?: TExtensions & JsonSafeExtensionShape<TExtensions> & StringKeyedExtensionShape<TExtensions> & { readonly [K in ReservedBodyField]?: never; }; }; /** * An RFC 9457 problem body. `type`/`title`/`status`/`detail`/`instance` are the * reserved members (`title` present only when a message was localized); * `code`/`category`/`retryable`/`fields`/`details` are documented extension * members the adapter writes by default. The body has a null prototype and is * deeply frozen, so it is safe to serialize and cannot carry prototype * pollution. */ type ProblemDetails<TDetails = unknown, TCode extends string = string, TExtensions extends object = Record<never, never>> = { readonly type?: string; readonly title?: string; readonly status: number; readonly detail?: string; readonly instance?: string; readonly code: TCode; readonly category?: string; readonly retryable?: boolean; readonly retryAfter?: number; readonly fields?: readonly FieldFault[]; readonly details?: TDetails; } & Readonly<Partial<TExtensions>>; /** Mapping diagnostics retained outside the serialized body. */ type ProblemDetailsOutcome = { /** Dynamic members dropped because they were not JSON-safe. */ readonly omitted: readonly OmittedMember[]; }; /** Framework-neutral status, headers, body, and diagnostics. */ type ProblemDetailsResult<TDetails = unknown, TCode extends string = string, TExtensions extends object = Record<never, never>> = { readonly status: number; readonly headers: Readonly<{ "content-type": typeof PROBLEM_DETAILS_JSON; "content-language"?: string; "retry-after"?: string; }>; readonly body: ProblemDetails<TDetails, TCode, TExtensions>; readonly outcome: ProblemDetailsOutcome; }; /** * Stage 3: transport. Maps a (possibly localized) {@link PublicError} to an RFC * 9457 result. The transport `source` is either a {@link PublicErrorCatalog} * (looks up `status`/`type` by public code) or an explicit {@link Transport} * `{ status, type? }` for catalog-free use; the machine members ride from the * view. A `title` and a `content-language` header appear only when the view was * localized, so the structure-only path is a first-class, RFC-valid response. * * This is the wire boundary: `details` and `fields` are deep-cloned into a * frozen, JSON-safe structure (a `Date`, `BigInt`, circular reference, or other * non-serializable value drops that member and records it in `outcome.omitted` * rather than throwing or leaking a value the next serializer would choke on). */ declare function toProblem<TDetails, TCode extends string = string, const TExtensions extends object = Record<never, never>>(source: PublicErrorCatalog | Transport, view: PublicError<TDetails, TCode> | LocalizedPublicError<TDetails, TCode>, context?: ToProblemContext<TExtensions>): ProblemDetailsResult<TDetails, TCode, TExtensions>; export { type CatalogResolution, type FieldFault, LocalizedMessageSet, type LocalizedMessageSetOptions, type LocalizedPublicError, type OmittedMember, type OnProject, PROBLEM_DETAILS_JSON, type ProblemDetails, type ProblemDetailsOutcome, type ProblemDetailsResult, type ProjectionOutcome, type ProjectionStatus, type PublicCodeOf, type PublicError, PublicErrorCatalog, type PublicErrorDescriptor, type ResolvedUserMessage, type ToProblemContext, type Transport, definePublicErrors, localize, project, projectWithDescriptor, resolveUserMessage, toProblem };