@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
TypeScript
/**
* 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 };