@shirudo/base-error
Version:
A cross-environment base error class for TypeScript applications, designed for seamless use across Node.js, browsers, and edge runtimes.
170 lines (108 loc) • 23.5 kB
Markdown
# Changelog
## 8.1.0 - 2026-07-04
Hardening release from a full code audit: two redaction gaps, totality of the traversal helpers, reference leaks at the curation boundaries, CPU-exhaustion bounds on the clone walkers, and a faster constructor. All changes are fixes within the documented contracts; the individually noted behavior tightenings are deliberate.
### Fixed
- **`redactAllow` no longer leaks subclass-added top-level fields.** A field a subclass added via `buildLogObject` (the documented extension pattern) inherited the root region's keep-everything, so every leaf beneath it survived the allow-list unmasked. Now only the library's own structural envelope (`name`/`message`/`stack`/`code`/`category`/`retryable`/`timestamp`/`timestampIso`/`cause`/`details`) is kept at the top level; any other top-level key and its whole subtree get the same leaf-level allow-list protection as `details`, so a newly added field leaks nothing by default. Logs may now mask subclass fields that previously passed through: allow-list their leaf keys explicitly to keep them. The deny-list (`redact`) is unaffected.
- **`toString()` honors a deny-listed `"message"`.** After `err.redact(["message", ...])`, the one-line render (and each redacted `BaseError` in the printed cause chain) shows the mask instead of the raw technical message, fail-closed if a function mask throws. The remaining scope is documented on `redact`/`redactAllow`/`redactWith`, in the observability guide, and as a new pitfall: `err.stack` (whose header carries the raw message) and Node's `console.log(err)` inspection stay unredacted, so route errors through a structured serializer that hits `toJSON` when redaction matters.
- **The cause-chain traversal helpers are total over circular chains.** `getRootCause`, `findInCauseChain`, `filterCauseChain`, `someCauseChain`, `everyCauseChain`, and the retryability helpers built on them no longer throw `"Circular cause chain detected"`. They run inside catch paths, where a circular chain (a bug in someone's error wiring) must not turn a retry decision into a new crash; `BaseError`'s own serialization already degrades gracefully on cycles. A cycle now terminates the traversal once the repeated node is reached: every node is visited exactly once (previously the repeated node was yielded a second time before the throw), `getRootCause` returns the deepest error before the repeat, and the predicate helpers evaluate over each distinct node. Callers that caught the circular-chain error can drop that handling. `maxDepth` is documented as the number of cause hops followed (up to `maxDepth + 1` nodes), matching existing behavior.
- **`project()` emits `fields` as a frozen, curated copy** of exactly `{ field, code }` per fault. Previously the projector's array and fault objects rode into the view by reference, so foreign extra properties on a fault (validator internals) survived curation, and later mutation of internal state reached the view. `details` intentionally stays by reference (the in-process view may hold rich values; `toProblem` remains the wire boundary); the `projectDetails` contract now documents that projectors must return fresh, vetted data.
- **`ValidationError.publicIssues()` emits `path` as a fresh copy** with object segments narrowed to `{ key }`. Previously the stored path array and its segment objects crossed the whitelist by reference, so extra properties a validator attached to a segment (and later mutations of the stored issue) reached the wire shape. A custom `mapIssue` replaces the whitelist entirely and must narrow forwarded paths itself; its JSDoc now says so.
- **The deep-clone paths carry a 100,000-node budget** as a CPU-exhaustion guard: cycles were already rejected, but shared (DAG) references clone once per reference, so a small hostile value could legally expand exponentially. Past the budget, `cloneJsonSafe` (the `toProblem` wire boundary) fails like any other non-JSON-safe value (the member drops to `outcome.omitted`), `defineErrors` rejects the metadata at definition time, log redaction degrades the subtree to a `"[Max redaction size exceeded]"` marker, and a plain-object `cause` degrades to the descriptive fallback marker during log serialization (enforced by a counting replacer on the native `JSON.stringify` round-trip, which measures faster than any JS-walker replacement).
- **`defineErrorClassSet` validates its definition more strictly**: it rejects empty/whitespace-only keys (matching `defineErrors`) and throws at definition time when a base class is listed before one of its subclasses, where first-match-wins dispatch would silently make the subclass handler unreachable; the error message names both keys and the fix (list subclasses first). Previously-accepted definitions with that ordering bug now fail fast.
- **`StructuredError.fromJSON` copies `details` shallowly** instead of keeping the payload's object by reference, so mutating the input payload after reconstruction no longer changes the error's details (top level; nested values stay shared, as documented).
- **`timestampIso` is derived from `timestamp`** instead of a second clock read, so the two fields can no longer disagree when construction straddles a millisecond boundary.
- **The JSR manifest declares a publish filter** (`src` without tests, plus README/LICENSE/CHANGELOG), so a `jsr publish` no longer ships `proposals/`, `docs/`, `coverage/`, `examples/`, or `dist/` to the registry.
### Changed
- **Stack traces are captured eagerly but symbolized and filtered lazily** on the first `stack` read, and the constructor's `setPrototypeOf` runs only when the prototype actually needs repair (ES5-transpiled subclasses). Constructing an error whose stack is never read no longer pays for V8 stack formatting: about 37% faster construction at 50-frame call depth in a local benchmark, in exchange for a few percent extra on the first read. `stack` is a memoizing accessor until first read (then a plain writable property); assigning `stack` before the first read wins unfiltered, and filtering, header rewriting, and non-V8 fallback behavior are unchanged.
## 8.0.0 - 2026-06-25
### Added
- New `@shirudo/base-error/public-error` subpath: a public-error pipeline of three independent stages over one descriptor per public code, `project` (curation, total over `unknown`, message-free), `localize` (optional localization, keyed on the public code), and `toProblem` (RFC 9457 transport producing a JSON-safe, frozen `ProblemDetails` body). Serves three consumption modes from one registration: client-localizing (SPA/Edge), backend-localizing (SSR/email), and a consumable third-party API. Includes a typed public-code union (`PublicCodeOf`) for exhaustive client branching, typed `extensions` on the problem body, an `onProject` observability hook, catalog-free entry points (`projectWithDescriptor`, explicit transports), and registration-time validation and conflict checks for the wire identity. See proposal 0011 and the runnable `examples/public-error-e2e.ts`.
### Removed
- **BREAKING:** the `@shirudo/base-error/presentation` and `@shirudo/base-error/problem-details` subpaths, superseded by the public-error pipeline. `PublicErrorPresenter` / `PublicErrorRegistry` / `PublicErrorDefinition` are replaced by `project` + `localize` + `PublicErrorCatalog` + `PublicErrorDescriptor`; `defineProblemDetailsAdapter` by `toProblem`. The localization primitives `LocalizedMessageSet` and `resolveUserMessage` now live on the `public-error` subpath.
### Changed
- Extracted shared internal helpers (a JSON-safe clone-and-freeze, the RFC 9457 status/type validation and the `application/problem+json` media type, and the code-then-predicate error resolution) into `src/utils`, reused across the library.
## 7.1.1 - 2026-06-23
### Fixed
- `matchError` now looks up handlers by own property only, so an error `code` that collides with an `Object.prototype` member (`toString`, `valueOf`, `hasOwnProperty`, `constructor`, …) routes to its explicit case or the `_` catch-all instead of an inherited method. Such codes are already valid in `defineErrors`, so a catalog union could previously mis-dispatch (or throw a confusing error) on match.
- `BaseError` cause serialization (`toLogObject`/`toJSON`) caps the cause chain at depth 100, matching `StructuredError.fromJSON` and the traversal helpers, so a pathologically deep but acyclic chain no longer risks a stack overflow while logging. Beyond the cap the chain ends in `"[Max cause depth exceeded]"`.
- Log redaction (`redact`/`redactAllow`/`redactWith`) now clones into null-prototype objects, so an own `__proto__` (or `constructor`) key in untrusted `details` (e.g. from `JSON.parse`/`fromJSON`) is masked as ordinary data instead of routing through a prototype setter. Closes a local prototype-reassignment footgun on the redacted log clone; global prototypes were never affected. (OWASP Prototype Pollution Prevention.)
- Log redaction caps its walk depth at 100: a pathologically deep `details` tree degrades to a `"[Max redaction depth exceeded]"` marker at the deep end (shallow fields survive) instead of overflowing the stack and tripping the fail-closed path. The bound is host-stack independent, so redaction behaves identically on small isolate stacks (edge runtimes).
### Documentation
- Documented on `toLogObject`/`toJSON` that the output is a log shape carrying the technical message, stack, cause chain and raw `details`, must never be returned to a client, and that the `presentation` subpath is the client-safe path. The `toJSON`/`toLogObject` equality is intentional: it is the shape `StructuredError.fromJSON` reconstructs.
## 7.1.0 - 2026-06-22
### Added
- Added the optional `@shirudo/base-error/problem-details` subpath with a framework-neutral RFC 9457 adapter for safe `PublicErrorView` values.
- Added finite public-code mappings, an explicit fallback, consistent HTTP/body status output, localized titles, occurrence details and instances, JSON-safe extensions, immutable snapshots, mapping diagnostics, and compile-time collision protection.
- Added literal public-code typing to `PublicErrorView<TDetails, TCode>` while preserving the existing default `string` code type.
## 7.0.0 - 2026-06-22
### Breaking Changes
- Catalog factories now live under `catalog.create`: migrate `AppErrors.CODE(message, options)` to `AppErrors.create.CODE(message, options)`.
- Catalog definitions declare transport-neutral `metadata` instead of the fixed top-level `httpStatus`; read it through `AppErrors.meta(code).metadata`.
- Catalog detail shapes use `detailsType<T>()` instead of consumer-side type assertions.
- Catalog definitions must be non-empty finite plain objects with non-empty string codes and are snapshotted and frozen at creation.
### Added
- Added catalog-local provenance guards: `AppErrors.is(value)` narrows to the catalog union and `AppErrors.is(value, code)` narrows to one generated error type. Forged, reconstructed, mutated, and foreign-catalog errors fail closed.
- Added `CatalogErrorOf<Catalog, Code>`, immutable `codes`, JSON-safe generic catalog metadata, and catalog-level deny/allow log-redaction policies.
- Error codes no longer collide with catalog operations, so codes such as `meta`, `create`, and `is` are valid.
## 6.3.0 - 2026-06-21
### Added
- Added `defineErrorClassSet` for reusable exhaustive matching over a finite set of local Error classes. Definitions preserve literal string keys, reject empty or duplicate constructor sets, snapshot their input, and require an exact handler table with precise inputs and result unions.
## 6.2.0 - 2026-06-21
### Added
- Added `matchThrown` and `ThrownMatcher` for immutable, first-match-wins handling of arbitrary caught values with constructor cases, constructor groups, type guards, boolean predicates, an explicit fallback, precise result unions, and native promise inference.
## 6.1.0 - 2026-06-21
### Added
- Added general-purpose guards for caught `unknown` values: `isError`, `hasErrorCode`, `isErrorOf`, `isAnyErrorOf`, and `isAllOf`, plus the `ErrorLike`, `ErrorClass`, and `TypeGuard` types. Structural guards fail closed on hostile property access; constructor guards retain precise class unions, and guard composition narrows to intersections.
## 6.0.0 - 2026-06-13
### Breaking Changes
- The core is now purely technical. Localization, the `expose` flag, and every public serializer were removed from `BaseError` and `StructuredError`. Removed: `toPublicJSON()`, `toProblemDetails()`, `toErrorResponse()`, `withUserMessage()`, `addLocalizedMessage()`, `updateLocalizedMessage()`, `getUserMessage()`, `withPublicCode()`, `withPublicMessage()`, `exposeToClients()`, the `expose` flag, and `publicCode`/`publicMessage` on `ErrorOptions` and the catalog `ErrorSpec`. The `TPublicCode` generic parameter is gone.
- Removed the entire response layer: `errorResponse`, `successResponse`, `createErrorResponse`, `createSuccessResponse`, `ErrorResponseBuilder`, `ApiResponse`, `SuccessResponse`, `ErrorResponse`, `LocalizedMessage`, and the `ProblemDetails` / `ProblemDetailsOptions` types.
- `StructuredError.fromJSON` no longer restores user or localized messages.
- `defineErrors` keeps `httpStatus` and `meta(code)` but no longer accepts `publicCode` / `publicMessage`.
- Requires Node.js `>=20`.
See the [migration guide](./MIGRATION.md) for the removed-API to replacement mapping.
### Added
- New optional subpath export `@shirudo/base-error/presentation` for safe, localized, transport-neutral public output: `LocalizedMessageSet`, `resolveUserMessage`, `PublicErrorDefinition`, `PublicErrorRegistry` (with `assertCoverage`), and a total `PublicErrorPresenter` that produces a `PublicErrorView`. Transport (HTTP status, gRPC, CLI exit code) is a consumer adapter concern.
### Changed
- The module and edge boundaries are enforced by ESLint: the core may not import the presentation module, and library source may not use Node globals (`process`, `Buffer`) or `node:*` imports. The runtime-pure suite also runs on workerd (via `@cloudflare/vitest-pool-workers`) in CI.
## 5.0.0 - 2026-06-01
### Breaking Changes
- `StructuredError.toProblemDetails()` and `StructuredError.toErrorResponse()` are safe by default. They no longer expose technical messages, internal codes, categories, or raw `details` unless public fields or explicit exposure options are used.
- Safe-by-default is invariant: standard Problem Details members (`type`, `title`, `status`, `detail`, `instance`) and library members (`code`, `category`, `retryable`, `traceId`) always win over colliding extension keys. There is no override switch.
- Raw `details` never cross into client responses. Surfacing details is always an explicit `mapDetails` projection on both `toProblemDetails()` and `toErrorResponse()`; full-fidelity details remain available for observability via `toLogObject()`.
- The `_tag` discriminant and inferred `name` derive from a single resolved name (an explicit `name` option, otherwise the constructor name), so they never diverge and an explicit `name` stabilizes both. `StructuredError` fixes `_tag` to the stable literal `"StructuredError"`, making the discriminant minification-safe out of the box; subclasses inherit it (override with a literal for a distinct tag). Narrow on `code` to distinguish individual structured errors.
- `typescript` is no longer a peer dependency. The package still ships TypeScript declarations.
- Updated the package version from `4.7.0` to `5.0.0`.
### Added
- Added `toLogObject()` for explicit logging serialization with stack and cause chains.
- Added `toPublicJSON()` for client-safe serialization.
- Added `publicCode`, `publicMessage`, and `expose` options to map internal domain/infrastructure errors to stable public API errors.
- Added `BaseErrorOptions` and exported it from the package root.
- Added `ProblemDetailsOptions` and exported it from the package root.
- Added `detail` to `toProblemDetails()` options so boundary layers can provide a public, client-safe message separately from the technical error message.
- Added `extensions` for explicit public Problem Details extension members.
- Added `mapDetails` to `toProblemDetails()` and `toErrorResponse()` for DDD-friendly boundary mapping from raw domain/application details to public members. This is the only path for surfacing details to clients. It is invoked only when the error carries `details` and receives a defined `TDetails`, so callbacks never have to guard against `undefined`.
- Added `publicCategory` to `toProblemDetails()` for projecting a deliberate, client-safe category (symmetric with `toErrorResponse()`).
- `guard()` now also accepts an error factory (`() => BaseError`) so the error is constructed only when the assertion fails.
- Added `matchError(error, cases)`: exhaustive, type-narrowing dispatch on a structured error's `code`. Omitting a case is a compile error unless a `_` catch-all is given; each handler receives the error narrowed to its case, and the result type is the union of the handler return types.
- Added `redact(keys, { mask? })`, `redactAllow(keys, { mask? })` and `redactWith(fn)`: sticky, opt-in PII redaction on the **log** path (`toLogObject`/`toJSON`), so even a logger's `JSON.stringify(error)` is masked. `redact` deep-masks matching keys (deny-list, default mask `"[REDACTED]"`, configurable); `redactAllow` is an allow-list that masks every `details` leaf except the listed ones (higher assurance, meaning new fields leak nothing); `redactWith` transforms the whole log object (e.g. scrubbing free text in `message`, or delegating to a dedicated redaction library). The mask may be a string or a function `(value, key) => unknown` for partial masking (`****6789`) or type preservation.
- `StructuredError.fromJSON` now restores author-provided `userMessage` and localized messages on round-trip (previously dropped). Documented that it always returns a base `StructuredError`: subclass identity (e.g. `ValidationError`) and `publicCode`/`publicMessage` (not in the log shape) are not reconstructed.
- `defineErrors` now rejects `"meta"` as an error code (it is the metadata accessor), and `meta(code)` throws a clear "unknown error code" error for codes absent from the catalog (instead of returning `undefined` and crashing the `.httpStatus` access) and returns a copy of the spec row so callers can't mutate the catalog.
- `toStructuredError` now returns `StructuredError<string, string>` instead of the option's literal code/category. A pre-existing `StructuredError` passes through unchanged, so promising the option literal in the return type was unsound (downstream `code === '…'` / matchError would compile but never match).
- Hardened redaction. `redactAllow` masks every non-allowed leaf across **any** data region (a `details` subtree at any depth and a cause's data fields) while leaving the top-level envelope and a cause's structural envelope keys (`name`/`message`/`stack`/`code`/`category`/`retryable`) intact; any _other_ field on a cause is data, so an object that merely resembles a structured error can't smuggle siblings past the allow-list (the classification is by position, not by a spoofable shape check). The deny-list (`redact`) masks matching keys at any depth, **including inside class instances**. The shared walker treats `Date`/`Map`/`Set` (and other own-key-less objects) as preserved leaves instead of collapsing them to `{}`, and descends into objects that carry own enumerable keys. The fail-closed marker keeps non-sensitive triage fields (`name`/`code`/`category`/`retryable`/timestamps).
- Added `partialMask({ keepStart?, keepEnd?, fill? })`: a `RedactMask` builder that reveals a prefix/suffix and masks the middle (`sk_live…AbCd`), useful to show _which_ secret it was. Fully masks values too short to reveal safely (`length <= keepStart + keepEnd`) and non-strings. A throwing redactor is **fail-closed**: it neither crashes the logging path nor leaks the payload. The client serializers are unaffected (already safe). Defense-in-depth, not a replacement for logger-level redaction.
- Added `StructuredError.fromJSON(json)`, the inverse of `toJSON`: reconstructs a typed `StructuredError` (with `code`/`category`/`retryable`/`details`, the original `stack`/`timestamp`, and the cause chain) from the serialized shape. For reconstruction within one trust/bounded-context boundary (worker/`postMessage`, queues/storage, log replay); lenient (malformed input → a safe envelope, never throws) and prototype-pollution-safe (whitelisted fields only). Across services, translate through an ACL rather than trusting reconstructed fields.
- Added `ValidationError`: an aggregate that collects N field-level issues into one `StructuredError`. Issues match the Standard Schema `Issue` shape (so Zod/Valibot/ArkType/TanStack Form output pipes in), are kept in full for logs, and cross to a client only via the explicit `publicIssues()` whitelist (`message`/`path`/`code?`/`pointer?`, never raw validator extras). `mapIssue` emits any wire shape (e.g. RFC-7807 `{ name, reason }`). Exposes `ValidationIssue`/`PublicIssue` types.
- Added `toStructuredError(value, options?)`: coerces any caught value into a `StructuredError` (a consistent boundary envelope; a `StructuredError` passes through, other `Error`s are preserved as `cause`). Honest defaults (`UNKNOWN_ERROR`/`INTERNAL`/non-retryable) and an optional second parameter so it fits the `errorMapper` slot of a `Result` type.
- Added `defineErrors(catalog)`: a declarative error catalog that generates a typed factory per `code` (with `category`, `retryable` and the public mapping baked in) plus a `meta(code)` accessor for boundary metadata such as `httpStatus`. Exposes the `ErrorSpec`, `Catalog` and `CatalogError` types; `CatalogError<typeof catalog>` is the closed union to pass to `matchError`.
- The public serializers (`toPublicJSON`, `toProblemDetails`, `toErrorResponse`) accept `locale` / `fallbackLocale`. When a matching author-provided localized message exists it becomes the public message, surfaced without `expose`, since these strings are client-safe by design. An explicit `message`/`detail` still wins, and missing locales fall back to `publicMessage` without leaking the default user message.
- Added package metadata for `sideEffects`, `engines`, `packageManager`, and homepage.
### Fixed
- `StructuredError` now captures stack headers with the configured error code instead of rewriting `name` after stack capture.
- Cause-chain traversal (`getRootCause`, `findInCauseChain`, `filterCauseChain`, `someCauseChain`, `everyCauseChain`, and the retryability helpers) no longer steps onto a spurious `undefined` when an error carries an explicit `cause: undefined` (e.g. `new Error(msg, { cause: undefined })`).
- `toProblemDetails()` return type no longer falsely includes the raw `details` shape; it reflects only the mapped/explicit extensions actually present at runtime, so the type can no longer invite reading internal fields that are absent.
- `mapDetails` is invoked only when the error carries `details`, so a naive mapper can no longer throw while serializing an error inside an error handler.
- Build no longer runs `lint:fix`; it verifies lint deterministically.
- CI now runs `pnpm test:run` explicitly.
### Migration
See [`MIGRATION.md`](./MIGRATION.md) for v4-to-v5 migration examples.