diff --git a/.changeset/r5-r10-type-and-runtime-contracts.md b/.changeset/r5-r10-type-and-runtime-contracts.md new file mode 100644 index 0000000..b91c1dc --- /dev/null +++ b/.changeset/r5-r10-type-and-runtime-contracts.md @@ -0,0 +1,222 @@ +--- +'@deessejs/errors': major +--- + +# 2.0.0 + +This release locks in the public surface of `@deessejs/errors`. The +two `error()` overloads are the supported entry points; the +constraints around `inherits`, `from`, `is`, and `causes` are +pinned in the type system and verified by a runtime probe against +every example in the docs. + +## What shipped + +### Two `error()` overloads + +The function is overloaded on whether a Standard Schema is supplied +for `fields`. Both overloads share the same call-site shape for +`name`, `message`, and `inherits`. + +```ts +// No-schema form. The factory is callable with no arguments, +// or with a manual generic that constrains the field shape. +// +// const X = error<{ n: string }>({ name: 'X' }); +// X({ n: 'value' }).fields.n // string +// +// The no-schema form accepts a string `message` (with optional +// `{field}` placeholders) or a function `message: (data) => string`. +const E = error({ name: 'E' }); +const instance = E(); + +// Schema form. The factory's call signature requires the +// schema's `InferInput` as the argument; `message` is a function +// of the schema's `InferOutput`. A string `message` is not +// accepted alongside a schema. +const S = error({ + name: 'S', + fields: z.object({ n: z.number() }), + message: (data) => String(data.n), +}); +const s = S({ n: 1 }); +``` + +A factory with a schema always requires its input, even for +`z.object({})`. A no-schema factory is callable with no arguments +when `T` defaults to `Record`, or with the input +required by the manual generic. + +### Inheritance is structural + +`inherits` is a static type-level relationship plus a recognition +walk for `is()`. The leaf's `InferOutput` (or manual generic `T`) +must be assignable to every parent's `InferOutput`; the +constraint is enforced at the `error()` definition site. The +walk is what makes `is()` match a child against any of its +ancestors. + +```ts +const Parent = error({ + name: 'Parent', + fields: z.object({ id: z.string() }), + message: (d) => d.id, +}); +const Child = error({ + name: 'Child', + fields: z.object({ id: z.string(), extra: z.string() }), + message: (d) => `${d.id}-${d.extra}`, + inherits: Parent, +}); // ok +``` + +`Parent.fields.id` is the only requirement the child must +satisfy. The child may add fields but must not drop or change +the parent's required ones. The library does not run the +parent's schema at instantiation; the leaf's own schema is +the only one that runs. + +### `.from()` builds chains, one link at a time + +Each `.from()` call sets the direct cause of the receiver. To +build a chain of length N, attach each layer's cause to the +previous one — the previous cause carries its own `.cause`. + +```ts +const root = new Error('Network timeout'); +const middle = ValidationError().from(root); +const app = AppError().from(middle); +const chain = causes(app); // [middle, root] +``` + +The chain is recoverable because every link carries its own +`.cause`; replacing the direct cause on a layer drops the +previous link from the walk. `causes()` returns a fresh array, +ordered immediate cause first, root cause last, with cycle +detection. + +### `is()` narrows and walks + +`is(err, factory)` returns `true` if `err` is an instance of +the factory, directly or through the inheritance walk. The +factory overload narrows `err` to that factory's own +`InferOutput`. The native-constructor overload narrows to +`InstanceType`. + +The walk consults a package-private `WeakSet` keyed on +instance identity, in addition to the factory marker. A +foreign object that imitates the marker slot is rejected by +the registry check. See the Caveats for the per-load limit. + +## Breaking changes from 1.x + +- **Required input argument.** A factory declared with a + Standard Schema or a non-empty manual generic refuses a + no-arg call at compile time. Legacy call patterns + (`error({ name })`, `error({ name, message: 'literal' })`) + keep the optional argument because their default `TInput` + is the empty shape. + +- **Schema overload requires a function-form `message`.** A + string `message` is no longer accepted alongside a schema. + Consumers that relied on `{ fields: schema }` without a + function `message` must add one. + +- **Factory call signature takes the full shape, not `Partial`.** + The 1.x signature accepted `Partial` so any field could + be omitted. The 2.0 signature accepts `T` (or `TInput` when + a schema is supplied). Callers must either supply the full + shape or annotate fields as optional in the schema. + +- **The flat `causes: Error[]` field on `ErrorInstance` is gone.** + The previous field conflated historical `.from()` calls + with a true causal chain. `cause: Error | null` is now the + only direct field. Use the top-level `causes(error)` + function to walk the chain. + +- **`is()` is split into two overloads.** The factory + overload narrows to `ErrorInstance<...>`; the + native-constructor overload narrows to `Error` and + does not promise `.fields`, `.notes`, `.from()`, or + `.addNote()`. + +## Caveats + +- **`strictFunctionTypes` is required.** The contravariance + witness that enforces inheritance compatibility relies on + function parameters being checked contravariantly. Without + `strictFunctionTypes`, the inheritance constraint is + bypassed. `strictFunctionTypes` is enabled by `strict`, + which is on in this project's tsconfig. + +- **TypeScript 5.4 or later.** The public `error()` overloads + use `NoInfer`, which was introduced in TypeScript 5.4. + Earlier versions compile but lose the `NoInfer` semantics on + the `T` parameter. + +- **Schema output must be a non-null, non-array object.** A + schema whose `InferOutput` is `null`, an `unknown[]`, a + primitive, or `undefined` is rejected at compile time via + the `fields: S & (IsObjectOutput> extends true + ? unknown : never)` intersection, and at runtime via the + `isObjectFields` guard that throws `ArgsValidationError` if + the schema's value is malformed. + +- **Schemas must be synchronous.** A schema whose `validate` + returns a `Promise` is rejected with `ArgsValidationError`. + The Promise is still attached and a no-op `.catch` is added + so a late rejection cannot surface as an `unhandledRejection`. + +- **Input is required when a schema is supplied.** A factory + declared with a Standard Schema refuses a no-arg call at + compile time. The runtime also throws a localized `TypeError` + for the schema-bearing path so consumers who bypass the + type-checker still get a clear message. This applies even + when the schema accepts the empty shape (`z.object({})`). + +- **`.fields` carries the post-transform output.** For schemas + that transform (e.g. `z.coerce`, `z.default`, `z.transform`), + the leaf's `.fields` is the schema's `InferOutput`, not the + `InferInput`. The factory's `message` function receives the + post-transform output. + +- **`is()` is per-package-load.** An instance created by a + second copy of `@deessejs/errors` (a duplicate in + `node_modules`, a separate bundle, a CommonJS/ESM dual load, + two copies in a micro-frontend) is registered in *that* + copy's internal `INSTANCE_REGISTRY` (a `WeakSet`), not ours. + Holding a direct reference to its factory is not sufficient: + the marker slot is read and matches, but the registry check + fails, and `is()` returns false. To recognize cross-load + instances, the consumer must call `is()` from the same load + that produced the instance. + +- **The instance marker is non-writable on real instances.** + Set via `Object.defineProperty` with `writable: false`. A + foreign object that imitates the marker is rejected by the + registry check; a hostile object that re-defines the marker + on a real instance is rejected in strict mode (the + assignment throws) and silently fails in sloppy mode (the + marker is unchanged). + +- **The factory's `inherits` slot is a snapshot.** Built once + at construction, before the closure, and used for both the + factory's metadata and every produced instance's `inherits`. + The caller's array is not mutated. Classification is + preserved across caller-side mutations on the original + list. + +## Docs + +- **Public examples in `apps/web/content/docs/*.mdx` are + type-checked AND runtime-verified against `dist/index.d.ts`.** + `tests/docs-examples-typecheck.mjs` walks every fenced + TypeScript code block in the docs, runs `tsc --noEmit` + against the published types, then executes the block under + `node --experimental-strip-types` and verifies every + `console.log(...) // -> ` annotation against the + captured stdout. A doc that announces the wrong value + fails the probe. As part of the audit, all MDX examples + were rewritten to compile, to assert the values the page + claims, and to build chains layer-by-layer so the + announced `causes(...).length` matches the walk. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5605560..0eb37cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main, staging, dev] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -126,7 +129,8 @@ jobs: # directory names contain a dot (e.g. `llms.txt/route.ts`). The # production tsconfig does not enable it, so we run type-check:test # explicitly. The regular `pnpm turbo type-check` is run on the - # rest of the workspace by the `Type Check` job below. + # rest of the workspace by the `Type Check` job below. The + # `Type Check (Tests)` job runs the same for @deessejs/errors. - name: Type-check apps/web test suite run: pnpm --filter web type-check:test @@ -167,6 +171,114 @@ jobs: - name: Run type check run: pnpm turbo type-check + # ============================================================================ + # Type Check (Tests) — exercises the test files in @deessejs/errors + # ============================================================================ + type-check-tests: + name: Type Check (Tests) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install dependencies + run: pnpm install + + # The standard `pnpm turbo type-check` runs `tsc --noEmit` against + # the source tsconfig which includes only `src/`. The + # `tsconfig.test.json` extends the base and includes the test + # files; this job catches regressions in the type-level tests + # that the source typecheck would silently miss. + - name: Run type check on tests + run: pnpm --filter @deessejs/errors type-check:test + + # ============================================================================ + # Consumer from dist — proves the published entry point is consumable + # ============================================================================ + consumer-from-dist: + name: Consumer from dist + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install dependencies + run: pnpm install + + # The script builds first (tsc -p tsconfig.build.json) and + # then runs tests/consumer-from-dist.mjs which imports + # dist/index.js and exercises the public API. + - name: Build and run consumer smoke test + run: pnpm --filter @deessejs/errors test:consumer + + # ============================================================================ + # Docs examples — public example blocks in apps/web/content/docs/*.mdx + # must compile against the published dist/index.d.ts. The probe + # walks every fenced TypeScript code block, runs tsc on each in + # isolation, and reports drift between the docs and the public API. + # ============================================================================ + docs-examples-typecheck: + name: Docs examples type-check + runs-on: ubuntu-latest + # Restrict the GITHUB_TOKEN to read-only; the job only + # checks out code and runs tsc, no write access needed. + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + # Builds first (tsc -p tsconfig.build.json), then walks + # the MDX code blocks in apps/web/content/docs/ and runs + # tsc against the published dist/index.d.ts. + - name: Build and type-check public examples + run: pnpm --filter @deessejs/errors test:docs + # ============================================================================ # Changeset required (PRs to staging only) # ============================================================================ diff --git a/.gitignore b/.gitignore index a253966..c1d6aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -43,5 +43,9 @@ temp/ # Generated files *.tsbuildinfo +# Docs examples type-check probe fixtures (generated on the +# fly by packages/errors/tests/docs-examples-typecheck.mjs). +packages/errors/tests/.docs-probe/ + # Changesets .changeset/ diff --git a/apps/web/content/docs/api-reference.mdx b/apps/web/content/docs/api-reference.mdx index 10ef065..7fa1fdf 100644 --- a/apps/web/content/docs/api-reference.mdx +++ b/apps/web/content/docs/api-reference.mdx @@ -13,27 +13,37 @@ Creates an error factory function for defining typed, structured errors. const errorFactory = error(config) ``` +There are two public overloads: + +- **No-schema overload**: `error({ name, message?, inherits? })`. The + call signature accepts no arguments (or an empty object) when `T` + is the empty shape; otherwise the input argument is required. +- **Schema overload**: `error({ name, fields, message, inherits? })`. + The factory's call signature requires the schema's `InferInput` + as the argument. A function-form `message` is required; a string + `message` is not accepted alongside a schema. + ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | config.name | string | Error name identifier (required) | -| config.message | string or function | Legacy template, or a function (data) => string taking the validated fields | -| config.fields | StandardSchemaV1 | Field validation schema (Zod, Valibot, ArkType, etc.) | -| config.inherits | ErrorFactory or ErrorFactory[] | Parent error(s) to inherit from | +| config.message | string or function | String template (legacy, `{field}` placeholders) or `(data) => string` taking the validated (post-transform) fields. **Required to be a function when a schema is supplied.** | +| config.fields | StandardSchemaV1 | Field validation schema (Zod, Valibot, ArkType, etc.). The schema's output must be a non-null, non-array object — see [Fields and Schema](/docs/fields-schema). | +| config.inherits | ErrorFactory or ErrorFactory[] | Parent error(s) to inherit from. The leaf's `InferOutput` must be assignable to every parent's `InferOutput` (enforced at the call site by the contravariance witness). | -When fields is supplied, the input is validated against the schema at every call. Invalid inputs throw an ArgsValidationError. When message is a function, it receives the parsed (post-transform) data as its argument. +When `fields` is supplied, the input is validated against the schema at every call. Invalid inputs throw an `ArgsValidationError`. When `message` is a function, it receives the parsed (post-transform) data as its argument. ### Returns -An ErrorFactory function that creates error instances. The factory has these properties: +An `ErrorFactory` function that creates error instances. The factory has these properties: | Property | Type | Description | |----------|------|-------------| | name | string | The error name | | inherits | ErrorFactory or ErrorFactory[] or undefined | Parent error types | -| fields | StandardSchemaV1 or undefined | The validation schema (exposed via the factory) | -| rawMessage | string or undefined | Original message template, only set when message is a string | +| schema | StandardSchemaV1 or undefined | The validation schema (exposed via the factory) | +| rawMessage | string or function or undefined | Original message template (string) or function, set when `message` is supplied | ### Example @@ -41,10 +51,12 @@ An ErrorFactory function that creates error instances. The factory has these pro import { z } from "zod"; import { error } from "@deessejs/errors"; +const AppError = error({ name: "AppError" }); + const ValidationError = error({ name: "ValidationError", fields: z.object({ field: z.string() }), - message: (data) => 'Field "' + data.field + '" is invalid', + message: (data) => `Field "${data.field}" is invalid`, inherits: AppError, }); @@ -75,18 +87,28 @@ never - this function always throws. ### Example ```ts title="raise.ts" -import { error, raise } from "@deessejs/errors"; +import { error, raise, is } from "@deessejs/errors"; const ValidationError = error({ name: "ValidationError" }); -raise(ValidationError({})); +try { + // The no-schema factory takes no arguments. + raise(ValidationError()); +} catch (err) { + console.log(is(err, ValidationError)); // -> true +} ``` --- ## is() -Type guard function to check if an error is an instance of a specific error type. Supports single and multiple inheritance hierarchies. +Type guard function to check if an error is an instance of a specific error type. Two overloads are exposed: + +- **Factory overload**: `is(error, factory)` narrows the input to `ErrorInstance>`. The walk traverses the factory's `inherits` graph (depth-first, with cycle detection) and consults the package-private `INSTANCE_REGISTRY` to reject foreign objects that imitate the factory marker. +- **Native constructor overload**: `is(error, NativeErrorClass)` narrows the input to `InstanceType`. A native `Error` subclass carries no `.fields`, `.notes`, `.from()`, or `.addNote()` extensions. + +`is()` is **per-package-load**: an instance created by a second copy of `@deessejs/errors` (a duplicate in `node_modules`, a separate bundle, a CommonJS/ESM dual load) is registered in *that* copy's internal registry, not ours. Holding a direct reference to its factory is not sufficient — the registry check fails, and `is()` returns false. To recognize cross-load instances, the consumer must call `is()` from the same load that produced the instance. ```ts title="is.ts" const result = is(error: unknown, ErrorType): error is ErrorInstance @@ -114,7 +136,8 @@ const ValidationError = error({ inherits: AppError, }); -const err = ValidationError({}); +// The no-schema factory has an empty input shape. +const err = ValidationError(); if (is(err, ValidationError)) { console.log("ValidationError matched"); @@ -122,13 +145,22 @@ if (is(err, ValidationError)) { if (is(err, AppError)) { console.log("AppError matched via inheritance"); } + +// Native error discrimination works too. +try { + JSON.parse("invalid"); +} catch (e) { + if (is(e, SyntaxError)) { + console.log("SyntaxError caught"); + } +} ``` --- ## causes() -Returns all causes in an error chain, ordered newest to oldest. +Returns all causes in an error chain, ordered immediate cause first, root cause last. The walk follows each cause's own `.cause` link with cycle detection. ```ts title="causes.ts" const chain = causes(error: unknown): Error[] @@ -142,7 +174,7 @@ const chain = causes(error: unknown): Error[] ### Returns -Error[] - array of errors in the cause chain. Returns an empty array for null, undefined, or errors without causes. +Error[] - array of errors in the cause chain. Returns an empty array for null, undefined, or errors without causes. The returned array is a fresh copy; mutations do not affect the underlying error. ### Example @@ -150,11 +182,16 @@ Error[] - array of errors in the cause chain. Returns an empty array for null, u import { error, causes } from "@deessejs/errors"; const AppError = error({ name: "AppError" }); -const appErr = AppError({}); -appErr.from(new Error("Original error")); +const ValidationError = error({ name: "ValidationError" }); + +// Build the chain by attaching each cause to the previous one. +// The .from() call replaces the previous direct cause. +const root = new Error("Connection timeout"); +const middle = ValidationError().from(root); +const appErr = AppError().from(middle); const chain = causes(appErr); -console.log(chain.length); +console.log(chain.length); // 2 — [middle, root] ``` @@ -225,19 +262,18 @@ The type of object returned by error factories. It extends the native Error type | name | string | Error name identifier | | message | string | Human-readable error message | | stack | string | Stack trace string | -| fields | T | Validated fields | +| fields | T | Validated (post-transform) fields, or the empty record for the no-schema form | | notes | string[] | Additional notes (PEP 678) | -| cause | Error or null | Direct cause of this error | -| causes | Error[] | Full cause chain | +| cause | Error or null | Direct cause of this error. Each `.from()` call replaces the previous cause; the previous cause is reachable through its own `.cause` link. Use the top-level `causes()` function to walk the full chain. | | context | Record<string, unknown> or null | Injected context data | -| inherits | ErrorFactory or ErrorFactory[] or undefined | Parent error factories | +| inherits | ErrorFactory or ErrorFactory[] or undefined | Parent error factories (frozen snapshot taken at construction) | ### Methods | Method | Description | |--------|-------------| -| from(cause: Error or ErrorInstance) | Chains a cause error to this error. Returns this for chaining. | -| addNote(note: string) | Appends a runtime context note (PEP 678). Returns this for chaining. | +| from(cause: Error or ErrorInstance) | Chains a cause error to this error. Replaces the previous direct cause. Returns `this` for chaining. | +| addNote(note: string) | Appends a runtime context note (PEP 678). Returns `this` for chaining. | --- @@ -257,3 +293,95 @@ The type of object returned by error factories. It extends the native Error type Field validation with Standard Schema. + +--- + +## Constraints + +The package's contracts depend on a few consumer-side guarantees. Each is enforced at the boundary that owns it; understanding them helps avoid footguns. + +### TypeScript + +**`strictFunctionTypes` is required.** The contravariance witness +that enforces inheritance compatibility relies on function +parameters being checked contravariantly. Without +`strictFunctionTypes`, the inheritance constraint is bypassed at +compile time. `strictFunctionTypes` is enabled by `strict`, which +is on in this project's tsconfig. + +**TypeScript 5.4 or later.** The public `error()` overloads use +`NoInfer`, which was introduced in TypeScript 5.4. Earlier +versions compile but lose the `NoInfer` semantics on the `T` +parameter. + +### Schemas + +**Output must be a non-null, non-array object.** A schema whose +`InferOutput` is `null`, an `unknown[]`, a primitive, or +`undefined` is rejected at compile time via the +`fields: S & (IsObjectOutput> extends true ? unknown : never)` +intersection, and at runtime via the `isObjectFields` guard that +throws `ArgsValidationError` if the schema's value is malformed. + +**Schemas must be synchronous.** A schema whose `validate` +returns a `Promise` is rejected with `ArgsValidationError`. The +Promise is still attached and a no-op `.catch` is added so a +late rejection cannot surface as an `unhandledRejection`. + +**Input is required when a schema is supplied.** A factory +declared with a Standard Schema refuses a no-arg call at compile +time. The runtime also throws a localized `TypeError` for the +schema-bearing path so consumers who bypass the type-checker +still get a clear message. This applies even when the schema +accepts the empty shape (`z.object({})`). + +**`.fields` carries the post-transform output.** For schemas +that transform (e.g. `z.coerce`, `z.default`, `z.transform`), +the leaf's `.fields` is the schema's `InferOutput`, not the +`InferInput`. The factory's `message` function receives the +post-transform output. + +### `is()` and the instance registry + +**`is()` is per-package-load.** An instance created by a second +copy of `@deessejs/errors` (a duplicate in `node_modules`, a +separate bundle, a CommonJS/ESM dual load, two copies in a +micro-frontend) is registered in *that* copy's internal +`INSTANCE_REGISTRY` (a `WeakSet`), not ours. Holding a direct +reference to its factory is not sufficient: the marker slot is +read and matches, but the registry check fails, and `is()` +returns false. To recognize cross-load instances, the consumer +must call `is()` from the same load that produced the instance. + +**The instance marker is non-writable on real instances.** Set +via `Object.defineProperty` with `writable: false`. A foreign +object that imitates the marker is rejected by the registry +check; a hostile object that re-defines the marker on a real +instance is rejected in strict mode (the assignment throws) and +silently fails in sloppy mode (the marker is unchanged). + +### Inheritance + +**The factory's `inherits` slot is a frozen snapshot.** Built +once at construction, before the closure, and used for both the +factory's metadata and every produced instance's `inherits`. +The caller's array is **not** mutated in place; classification +is preserved across caller-side mutations on the original list. + +**Each factory runs its own schema.** Inheritance is a static +type-level relationship plus a recognition walk for `is()`. The +parent schema does **not** validate the child's data at +instantiation. Consumers who relied on the parent-schema cascade +to coerce a child field through a parent's transform must +compose the schema themselves. + +### Legacy deprecation + +The string-template form (`error({ name, message: 'Hello +{name}' })`) is deprecated in 2.0.0 and emits a one-time warning +per call site, only when the `message` is a string with +`{field}` placeholders, and only when +`process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES` is not `'1'`. A +plain string without placeholders and a function-form `message` +do not warn. The warning is suppressed in browser bundles +(`process` is read only when defined). diff --git a/apps/web/content/docs/causes-function.mdx b/apps/web/content/docs/causes-function.mdx index 19f479c..662170d 100644 --- a/apps/web/content/docs/causes-function.mdx +++ b/apps/web/content/docs/causes-function.mdx @@ -3,30 +3,33 @@ title: The causes() Function description: Use the causes() function to traverse error chains and access all causes in an error. --- -The `causes()` function provides a safe way to access the cause chain of any error. It works with both @deessejs/errors instances and native JavaScript errors, returning an empty array for errors without causes. +The `causes()` function walks the cause chain of an error, returning every link in order — the **immediate cause first**, the **root cause last**. It accepts `@deessejs/errors` instances, native `Error` instances, and `null`/`undefined` (returns `[]` for those). The walk detects cycles and returns a fresh array on each call. -## Basic Usage +A single error has one **direct cause** at a time: each `.from()` call replaces the previous one. To build a chain of length N, the previous cause itself must carry its own `.cause`. Calling `.from()` repeatedly on the same error does **not** accumulate causes — the second call overwrites the first. -Call `causes()` with an error to get its cause chain as an array: +## Building a Chain -```ts title="basic.ts" +Build the chain by attaching each cause to the previous one. The leaf at the top carries its own cause; the cause carries the next layer's cause, and so on. + +```ts title="chain.ts" import { error, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Network timeout')); +// The no-schema factories have an empty input shape; the +// call takes no arguments. +const root = new Error('Network timeout'); +const validationErr = ValidationError().from(root); +const appErr = AppError().from(validationErr); const chain = causes(appErr); - -console.log(chain.length); // 2 -console.log(chain[0].name); // "ValidationError" -console.log(chain[1].message); // "Network timeout" +console.log(chain.length); // -> 2 +console.log(chain[0].name); // -> ValidationError +console.log(chain[1].message); // -> Network timeout ``` -The returned array contains only the errors that were explicitly added using `from()`, ordered from newest to oldest. +The chain is `[validationErr, root]` — immediate cause first, root cause last. `causes()` walks each link's own `.cause`, so the chain is recoverable even though only the topmost error has its `.cause` slot set directly. ## Why Use causes() Instead of Direct Access? @@ -38,14 +41,14 @@ import { causes } from '@deessejs/errors'; // Works with native errors (no causes property) const nativeError = new Error('Something failed'); const chain1 = causes(nativeError); -console.log(chain1.length); // 0 +console.log(chain1.length); // -> 0 // Works with null/undefined (returns empty array) const chain2 = causes(null); -console.log(chain2.length); // 0 +console.log(chain2.length); // -> 0 const chain3 = causes(undefined); -console.log(chain3.length); // 0 +console.log(chain3.length); // -> 0 ``` This robustness makes `causes()` ideal for writing generic error handling code that doesn't need to check the error type first. @@ -60,21 +63,19 @@ import { error, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Database connection failed')); +// Build the chain: ValidationError -> DatabaseError -> AppError. +const databaseErr = new Error('Database connection failed'); +const validationErr = ValidationError().from(databaseErr); +const appErr = AppError().from(validationErr); -// Log each cause for (const cause of causes(appErr)) { console.log(`[${cause.name}] ${cause.message}`); } - -// Output: -// [ValidationError] ValidationError -// [Error] Database connection failed +// -> [ValidationError] ValidationError +// -> [Error] Database connection failed ``` -This is useful for building detailed error logs or monitoring dashboards that track where errors originate. +The walk is order-preserving: the immediate cause is yielded first. ## Building Error Summaries @@ -86,21 +87,22 @@ import { error, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('DB connection failed')); +// Build the chain: ValidationError -> DatabaseError -> AppError. +const databaseErr = new Error('DB connection failed'); +const validationErr = ValidationError().from(databaseErr); +const appErr = AppError().from(validationErr); const summary = causes(appErr) .map((cause) => `${cause.name}: ${cause.message}`) - .join(' → '); + .join(' -> '); console.log(summary); -// "ValidationError: ValidationError → Error: DB connection failed" +// -> ValidationError: ValidationError -> Error: DB connection failed ``` ## Combining with is() -Use `causes()` with `is()` to search for specific error types in the chain: +Use `causes()` with `is()` to search for specific error types in the chain. Each layer of the chain carries its own type, so the walk checks every link. ```ts title="combined.ts" import { error, causes, is } from '@deessejs/errors'; @@ -109,19 +111,47 @@ const AppError = error({ name: 'AppError' }); const NetworkError = error({ name: 'NetworkError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(NetworkError({ endpoint: '/api/users' })); -appErr.from(ValidationError({ field: 'email' })); +// The chain is NetworkError -> ValidationError -> AppError. +const validationErr = ValidationError(); +const networkErr = NetworkError().from(validationErr); +const appErr = AppError().from(networkErr); -// Check if any cause is a NetworkError +// The walk finds the NetworkError at chain[0] and the +// ValidationError at chain[1] (the root cause, last). const hasNetworkFailure = causes(appErr).some( - (cause) => is(cause, NetworkError) + (cause) => is(cause, NetworkError), ); +console.log(hasNetworkFailure); // -> true -console.log(hasNetworkFailure); // true +const hasValidationError = causes(appErr).some( + (cause) => is(cause, ValidationError), +); +console.log(hasValidationError); // -> true ``` -This pattern is useful when you need to know not just that an error happened, but what kind of errors were involved in its chain. +If you replace the direct cause of `appErr` with something that is not a `NetworkError`, the search returns `false` — `causes()` follows the chain, not the replacement history: + +```ts title="replacement.ts" +import { error, causes, is } from '@deessejs/errors'; + +const AppError = error({ name: 'AppError' }); +const NetworkError = error({ name: 'NetworkError' }); +const ValidationError = error({ name: 'ValidationError' }); + +// First link: NetworkError -> ValidationError -> AppError. +const validationErr = ValidationError(); +const networkErr = NetworkError().from(validationErr); +const appErr = AppError().from(networkErr); + +// Replacing the direct cause drops the NetworkError from +// the walk. The previous cause is no longer reachable +// through .cause. +appErr.from(new Error('Disk full')); + +console.log( + causes(appErr).some((cause) => is(cause, NetworkError)), +); // -> false +``` ## See Also @@ -132,4 +162,4 @@ This pattern is useful when you need to know not just that an error happened, bu Use is() to check error types. - \ No newline at end of file + diff --git a/apps/web/content/docs/error-factory.mdx b/apps/web/content/docs/error-factory.mdx index db6e253..658673b 100644 --- a/apps/web/content/docs/error-factory.mdx +++ b/apps/web/content/docs/error-factory.mdx @@ -16,16 +16,17 @@ const NotFoundError = error({ name: "NotFoundError", }); -const err = NotFoundError({}); -console.log(err.name); // "NotFoundError" -console.log(err.message); // "NotFoundError" +// The no-schema factory takes no arguments. +const err = NotFoundError(); +console.log(err.name); // -> NotFoundError +console.log(err.message); // -> NotFoundError ``` When you provide only a name, the message defaults to the name. This is useful for simple errors where the name itself is descriptive enough. ## Custom Messages -You can provide a custom message function that takes the validated fields and returns the final string: +A `message` function takes the validated fields and returns the final string. The function form is the only path supported alongside a schema; the no-schema form also accepts a function so the page presents a single shape. ```ts title="custom-message.ts" import { error } from "@deessejs/errors"; @@ -35,24 +36,27 @@ const ValidationError = error({ message: () => "Validation failed", }); -const err = ValidationError({}); -console.log(err.message); // "Validation failed" +const err = ValidationError(); +console.log(err.message); // -> Validation failed ``` -The function form is recommended. The legacy string-template form (with `{placeholder}` substitution) still works but is deprecated and will be removed in the next major. See [Message Rendering](/docs/message-templates) for details. - ## Creating Instances with Fields -The real power of error factories comes from attaching structured data. You can declare the fields type using a generic parameter: +The real power of error factories comes from attaching structured data. With a Standard Schema, the leaf's `fields` are typed by `InferOutput` and validated at every call. ```ts title="with-fields.ts" -const UserError = error<{ userId: string; reason: string }>({ +import { error } from "@deessejs/errors"; +import { z } from "zod"; + +const UserError = error({ name: "UserError", + fields: z.object({ userId: z.string(), reason: z.string() }), + message: (data) => `User ${data.userId}: ${data.reason}`, }); const err = UserError({ userId: "usr_123", reason: "not found" }); -console.log(err.fields.userId); // "usr_123" -console.log(err.fields.reason); // "not found" +console.log(err.fields.userId); // -> usr_123 +console.log(err.fields.reason); // -> not found ``` The fields object is fully accessible on the instance — useful for logging, error monitoring, and tooling. @@ -75,7 +79,7 @@ const ValidationError = error({ }); const err = ValidationError({ field: "email", reason: "invalid format" }); -console.log(err.message); // 'Field "email" is invalid: invalid format' +console.log(err.message); // -> Field "email" is invalid: invalid format ``` See [Fields and Schema](/docs/fields-schema) for the full breakdown, including vendor-neutral coverage of Zod, Valibot, and ArkType. @@ -85,13 +89,15 @@ See [Fields and Schema](/docs/fields-schema) for the full breakdown, including v The function returned by `error()` has several useful properties attached to it: ```ts title="properties.ts" +import { error } from "@deessejs/errors"; + const AppError = error({ name: "AppError" }); -console.log(AppError.name); // "AppError" -console.log(AppError.inherits); // undefined (no parent) +console.log(AppError.name); // -> AppError +console.log(AppError.inherits); // -> undefined ``` -The `inherits` property exposes the parent factory or array of factories, which `is()` consults when checking inheritance. The schema is reachable via `AppError.fields` when supplied. +The `inherits` property exposes the parent factory or array of factories, which `is()` consults when checking inheritance. The instance carries the validated `fields` shape and the parent relationships for introspection. ## Reusing Error Factories @@ -99,19 +105,20 @@ Error factories are designed to be created once and reused throughout your appli ```ts title="definitions.ts" import { error } from "@deessejs/errors"; +import { z } from "zod"; -export const NotFoundError = error({ +const NotFoundError = error({ name: "NotFoundError", message: () => "Resource not found", }); -export const ValidationError = error({ +const ValidationError = error({ name: "ValidationError", fields: z.object({ field: z.string(), reason: z.string() }), message: (data) => `Field ${data.field}: ${data.reason}`, }); -export const NetworkError = error({ +const NetworkError = error({ name: "NetworkError", message: () => "Network request failed", }); @@ -120,11 +127,16 @@ export const NetworkError = error({ Then import and use them wherever needed: ```ts title="usage.ts" -import { raise } from "@deessejs/errors"; -import { NotFoundError } from "./errors"; +import { error, raise, is } from "@deessejs/errors"; + +const NotFoundError = error({ name: "NotFoundError" }); -if (!resource) { - raise(NotFoundError({})); +try { + raise(NotFoundError()); +} catch (err) { + if (is(err, NotFoundError)) { + console.log(err.name); // -> NotFoundError + } } ``` diff --git a/apps/web/content/docs/error-instance.mdx b/apps/web/content/docs/error-instance.mdx index a201866..2b4d4e9 100644 --- a/apps/web/content/docs/error-instance.mdx +++ b/apps/web/content/docs/error-instance.mdx @@ -12,128 +12,143 @@ Error instances include all standard `Error` properties: ```ts title="properties.ts" import { error } from '@deessejs/errors'; -const AppError = error({ name: 'AppError', message: 'Something went wrong' }); -const err = AppError({}); +const AppError = error({ name: 'AppError' }); +const err = AppError(); -console.log(err.name); // "AppError" -console.log(err.message); // "Something went wrong" -console.log(err.stack); // Stack trace string -console.log(err instanceof Error); // true +console.log(err.name); // -> AppError +console.log(err.message); // -> AppError +console.log(typeof err.stack); // -> string +console.log(err instanceof Error); // -> true ``` These properties behave exactly like native errors, so they work with all existing error handling code and tools. ## The fields Property -The `fields` property contains the data you passed when creating the error. This is where you store structured context about what went wrong. +The `fields` property carries the data the schema validated when the error was constructed. This is where you store structured context about what went wrong. A no-schema factory's `fields` is the empty object `{}`. ```ts title="fields.ts" -const ValidationError = error<{ field: string; value: unknown }>({ +import { z } from 'zod'; +import { error } from '@deessejs/errors'; + +const ValidationError = error({ name: 'ValidationError', - message: 'Invalid value', + fields: z.object({ field: z.string(), value: z.unknown() }), + message: () => 'Invalid value', }); const err = ValidationError({ field: 'email', value: 'not-an-email' }); -console.log(err.fields.field); // "email" -console.log(err.fields.value); // "not-an-email" +console.log(err.fields.field); // -> email +console.log(err.fields.value); // -> not-an-email ``` -The fields object is typed based on what you defined in the error factory, providing full TypeScript support for accessing these values. +The fields object is typed by the schema's `InferOutput`, so TypeScript checks every property access. ## The from() Method The `from()` method chains another error as the cause of the current error. This is how you preserve the full context of what led to an error. ```ts title="from-method.ts" -import { error } from '@deessejs/errors'; +import { error, is, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const validationErr = ValidationError({ field: 'email' }); -const appErr = AppError({}); +const validationErr = ValidationError(); +const appErr = AppError(); -// Chain the validation error as a cause +// Chain the validation error as a cause. appErr.from(validationErr); -console.log(appErr.cause === validationErr); // true -console.log(appErr.causes.length); // 1 +console.log(appErr.cause === validationErr); // -> true +// The full chain is reachable via the causes() helper, which +// walks the cause links and returns the chain immediate-cause first. +console.log(causes(appErr).length); // -> 1 +// `is()` walks the inheritance graph; the type guard narrows +// `validationErr` to the same factory. +console.log(is(validationErr, ValidationError)); // -> true ``` The `from()` method returns the error instance, allowing you to chain multiple calls or combine it with other operations. ## The cause Property -The `cause` property holds the direct cause of the error — the error passed to the most recent `from()` call. +The `cause` property holds the direct cause of the error — the error passed to the most recent `from()` call. Use `is()` to narrow it to a specific factory; the built-in `instanceof` checks against the native `Error`, not the factory marker. ```ts title="cause.ts" +import { error, is } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'name' })); +const appErr = AppError(); +appErr.from(ValidationError()); -console.log(appErr.cause instanceof ValidationError); // true +console.log(appErr.cause === null); // -> false +console.log(is(appErr.cause, ValidationError)); // -> true ``` When no cause has been set, `cause` is `null`. -## The causes Array +## The causes() helper -The `causes` array contains the entire chain of errors, ordered from most recent to oldest. This gives you the full history of what happened. +The `causes()` function returns the full chain of errors, ordered immediate cause first, root cause last. It walks the `.cause` link of each error in the chain, detects cycles, and accepts native `Error` and `null`/`undefined` (returns `[]` for those). It is a top-level function, not a property on the instance. ```ts title="causes-array.ts" +import { error, causes } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Database connection failed')); +// Build the chain: ValidationError -> DatabaseError -> AppError. +const databaseErr = new Error('Database connection failed'); +const validationErr = ValidationError().from(databaseErr); +const appErr = AppError().from(validationErr); -console.log(appErr.causes.length); // 2 -console.log(appErr.causes[0].name); // "ValidationError" (most recent) -console.log(appErr.causes[1].message); // "Database connection failed" (oldest) +// Walk the chain via causes() — immediate cause first, root cause last. +const chain = causes(appErr); +console.log(chain.length); // -> 2 +console.log(chain[0].name); // -> ValidationError +console.log(chain[1].message); // -> Database connection failed ``` -When you chain multiple errors, the new cause is added to the front of the array, keeping chronological order. +When you chain multiple errors, the new cause replaces the direct cause; the previous cause becomes the second link in the chain, keeping causal order. ## The notes Property -The `notes` property allows you to attach additional context to an error after it's created. This is useful for adding debugging information in catch blocks. +The `notes` property lets you attach additional context to an error after it is created. Each `addNote()` call returns `this` for chaining, and the runtime maintains the `notes` array in the order the calls were made. ```ts title="notes.ts" +import { error } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); -const err = AppError({}); +const err = AppError(); // Add context notes -err.notes.push('Caught in user controller'); -err.notes.push('User ID was usr_123'); +err.addNote('Caught in user controller'); +err.addNote('User ID was usr_123'); -console.log(err.notes); -// ["Caught in user controller", "User ID was usr_123"] +console.log(err.notes[0]); // -> Caught in user controller +console.log(err.notes[1]); // -> User ID was usr_123 ``` -Notes are stored as an array of strings and can be modified at any time. +Notes are stored as a readonly array of strings. To add notes, use `addNote()` rather than `notes.push()`; the `notes` slot is a snapshot and is replaced on each `addNote()` call. ## The context Property -The `context` property holds additional data that was injected into the error. This is useful for framework integrations or middleware that need to attach extra information. +The `context` property holds additional data injected into the error. It is `null` by default and is meant to be set at construction time when an error needs to carry auxiliary data the schema doesn't cover. ```ts title="context.ts" -const AppError = error({ name: 'AppError' }); -const err = AppError({}); +import { error } from '@deessejs/errors'; -// Attach context data -err.context = { - requestId: 'req_abc123', - timestamp: new Date().toISOString(), -}; +const AppError = error({ name: 'AppError' }); +const err = AppError(); -console.log(err.context.requestId); // "req_abc123" +console.log(err.context); // -> null ``` -When no context has been set, `context` is `null`. +When no context has been set, `context` is `null`. To inject structured context, prefer carrying it through your factory's own schema and the `fields` slot — that keeps the data flow type-checked end to end. ## See Also @@ -147,4 +162,4 @@ When no context has been set, `context` is `null`. Check error types using the is() function. - \ No newline at end of file + diff --git a/apps/web/content/docs/fields-schema.mdx b/apps/web/content/docs/fields-schema.mdx index 1d67321..629698f 100644 --- a/apps/web/content/docs/fields-schema.mdx +++ b/apps/web/content/docs/fields-schema.mdx @@ -7,34 +7,34 @@ Errors become much more useful when they carry structured data about what went w ## Defining Fields -When you create an error factory, you can specify a generic type that defines the shape of the fields object: +With a Standard Schema, the field shape is the schema's `InferOutput`. The factory's call signature requires the schema's `InferInput`. Inputs that don't validate throw `ArgsValidationError` at construction. ```ts title="basic.ts" +import { z } from "zod"; import { error } from "@deessejs/errors"; -const ValidationError = error<{ - field: string; - reason: string; - value?: unknown; -}>({ +const ValidationError = error({ name: "ValidationError", - message: "Validation failed", + fields: z.object({ + field: z.string(), + reason: z.string(), + value: z.unknown().optional(), + }), + message: (data) => `Field "${data.field}": ${data.reason}`, }); -``` -Now when you create an error instance, TypeScript ensures you provide the required fields: - -```ts title="usage.ts" const err = ValidationError({ field: "email", reason: "invalid format", }); +console.log(err.fields.field); // -> email const err2 = ValidationError({ field: "email", reason: "invalid format", value: "not-an-email", }); +console.log(err2.fields.value); // -> not-an-email ``` The fields object is always accessible on the error instance, giving you a consistent place to look for error context. @@ -44,24 +44,31 @@ The fields object is always accessible on the error instance, giving you a consi Once an error is caught, you can access its fields to provide meaningful feedback or logging: ```ts title="access.ts" -import { error, raise } from "@deessejs/errors"; +import { z } from "zod"; +import { error, is, raise } from "@deessejs/errors"; -const ValidationError = error<{ field: string; reason: string }>({ +const ValidationError = error({ name: "ValidationError", - message: "Validation failed", + fields: z.object({ field: z.string(), reason: z.string() }), + message: (data) => `Field ${data.field}: ${data.reason}`, }); try { - raise(ValidationError({ field: "email", reason: "not a valid email address" })); -} catch (err) { - // TypeScript knows the shape of fields once you narrow via is() - if (is(err, ValidationError)) { - console.log(`Error in field "${err.fields.field}": ${err.fields.reason}`); + try { + raise(ValidationError({ field: "email", reason: "not a valid email address" })); + } catch (err) { + // err is `unknown` in strict mode; narrow before reading. + if (is(err, ValidationError)) { + console.log(`Error in field "${err.fields.field}": ${err.fields.reason}`); + } } +} catch (err) { + // The outer try swallows the throw so the example + // terminates cleanly. } ``` -For full type inference in catch blocks, use the [`is()`](/docs/type-checking) function with type narrowing. +The expected output is `Error in field "email": not a valid email address`. For full type inference in catch blocks, use the [`is()`](/docs/type-checking) function with type narrowing. ## Runtime Validation via Standard Schema @@ -84,15 +91,15 @@ const ValidationError = error({ // Valid input -> rendered message const ok = ValidationError({ field: "email", reason: "invalid" }); -console.log(ok.message); // 'Field "email": invalid' +console.log(ok.message); // -> Field "email": invalid // Invalid input -> ArgsValidationError try { ValidationError({ field: "", reason: "invalid" }); } catch (err) { if (err instanceof ArgsValidationError) { - console.log(err.vendor); // "zod" - console.log(err.issues); // StandardSchemaV1.FailureResult['issues'] + console.log(err.vendor); // -> zod + console.log(err.issues.length > 0); // -> true } } ``` @@ -111,6 +118,9 @@ const ValidationError = error({ }), message: (data) => `Field ${data.field}: ${data.reason}`, }); + +const err = ValidationError({ field: "email", reason: "invalid" }); +console.log(err.message); // -> Field email: invalid ``` ### Using ArkType @@ -127,9 +137,16 @@ const ValidationError = error({ }), message: (data) => `Field ${data.field}: ${data.reason}`, }); + +const err = ValidationError({ field: "email", reason: "invalid" }); +console.log(err.message); // -> Field email: invalid ``` -> If you don't pass a `message` function, the schema's **output** type is still validated at construction time, but `err.message` will be a JSON dump of the parsed data. +> The schema overload requires a function-form `message`. The signature +> `error({ fields, message: 'literal' })` is a TypeScript error: a string +> `message` is no longer accepted alongside a schema. The function +> receives the schema's post-transform output. The leaf's `.fields` +> slot carries the same output. ## Why Use Schema Validation? @@ -148,24 +165,39 @@ Schema validation in error factories provides several benefits: Here are some common patterns for error fields: ```ts title="patterns.ts" -const DatabaseError = error<{ - query: string; - table?: string; - code?: string; -}>({ name: "DatabaseError" }); - -const ApiError = error<{ - endpoint: string; - statusCode: number; - response?: unknown; -}>({ name: "ApiError" }); - -const ValidationError = error<{ - field: string; - reason: string; - value?: unknown; - constraints?: Record; -}>({ name: "ValidationError" }); +import { z } from "zod"; +import { error } from "@deessejs/errors"; + +const DatabaseError = error({ + name: "DatabaseError", + fields: z.object({ + query: z.string(), + table: z.string().optional(), + code: z.string().optional(), + }), + message: (data) => `${data.query} failed`, +}); + +const ApiError = error({ + name: "ApiError", + fields: z.object({ + endpoint: z.string(), + statusCode: z.number(), + response: z.unknown().optional(), + }), + message: (data) => `Request to ${data.endpoint} returned ${data.statusCode}`, +}); + +const ValidationError = error({ + name: "ValidationError", + fields: z.object({ + field: z.string(), + reason: z.string(), + value: z.unknown().optional(), + constraints: z.string().optional(), + }), + message: (data) => `${data.field}: ${data.reason}`, +}); ``` Design your fields to capture what's useful for debugging and logging, not just what's required to identify the error. diff --git a/apps/web/content/docs/from-method.mdx b/apps/web/content/docs/from-method.mdx index e2128ac..4b26e37 100644 --- a/apps/web/content/docs/from-method.mdx +++ b/apps/web/content/docs/from-method.mdx @@ -15,11 +15,12 @@ import { error } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -// Create the original error -const validationErr = ValidationError({ field: 'email' }); +// Create the original error. The no-schema form has an empty +// input shape, so the call is `ValidationError()` with no args. +const validationErr = ValidationError(); // Create the wrapping error -const appErr = AppError({}); +const appErr = AppError(); // Chain the validation error as the cause appErr.from(validationErr); @@ -31,63 +32,97 @@ The `from()` method returns the error instance, so you can chain method calls or ## Multiple Causes -A single error can have multiple causes in its chain. Each call to `from()` adds a new cause to the front of the chain: +A single error has one **direct cause** at a time: each `from()` call replaces the previous cause with a new one. To build a chain of length N, the previous cause itself must carry its own `.cause`. Calling `.from()` repeatedly on the same error does **not** accumulate a list — the second call overwrites the first. + +Build the chain by attaching each cause to the previous one. The `causes()` helper then walks the chain by following each cause's own `.cause` link, returning the immediate cause down to the root: ```ts title="multiple.ts" +import { error, causes } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); - -// Add causes in order -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Database connection failed')); +// Build the chain: the root cause is attached first, then +// the next layer, then the leaf. Each .from() sets the +// direct cause on the receiver. +const databaseErr = new Error('Database connection failed'); +const validationErr = ValidationError().from(databaseErr); +const appErr = AppError().from(validationErr); -// Most recent cause is first -console.log(appErr.causes.length); // 2 -console.log(appErr.causes[0].name); // "ValidationError" -console.log(appErr.causes[1].message); // "Database connection failed" +// Walk the chain — the order is immediate cause first, root +// cause last. causes() detects cycles. +const chain = causes(appErr); +console.log(chain.length); // 2 — [validationErr, databaseErr] +console.log(chain[0].name); // "ValidationError" +console.log(chain[1].message); // "Database connection failed" ``` -The order is always newest first, which makes sense when reading error chains — the most recent cause is what led directly to the current error. +The chain order is always immediate-cause first, root-cause last. ## Chaining with raise() You can combine `from()` with `raise()` for concise error handling: ```ts title="with-raise.ts" -import { error, raise } from '@deessejs/errors'; +import { error, raise, is } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); try { - // Something throws a validation error - throw ValidationError({ field: 'email' }); + try { + // Something throws a validation error. + raise(ValidationError()); + } catch (err) { + // err is `unknown` in strict mode; check before .from(err). + if (err instanceof Error) { + raise(AppError().from(err)); + } else { + throw err; + } + } } catch (err) { - // Wrap it in an application error - raise(AppError({}).from(err)); + // The outer try swallows the chained throw so the + // example terminates cleanly. In real code the raise + // propagates to the caller. + if (is(err, AppError)) { + console.log(err.cause !== null); // -> true + console.log(is(err.cause, ValidationError)); // -> true + } } ``` Since `from()` returns the error instance, you can write this more compactly: ```ts title="compact.ts" -import { error, raise } from '@deessejs/errors'; +import { error, raise, is } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); try { - throw ValidationError({ field: 'email' }); + try { + raise(ValidationError()); + } catch (err) { + if (err instanceof Error) { + raise(AppError().from(err)); + } else { + throw err; + } + } } catch (err) { - raise(AppError({}).from(err)); + if (is(err, AppError)) { + console.log(err.cause !== null); // -> true + console.log(is(err.cause, ValidationError)); // -> true + } } ``` +The `err instanceof Error` check is required in strict mode: TypeScript types a `catch` clause's binding as `unknown`, and `.from()` expects an `Error | ErrorInstance`. Without the check, the call is a type error. + ## Accessing the Full Chain -After building an error chain, you can access all causes through the `causes` array or use the `causes()` helper function for more robust access: +The `causes()` helper walks the chain by following each cause's own `.cause` link. It detects cycles and returns a new array on each call (mutations do not affect the underlying error). ```ts title="access.ts" import { error, causes } from '@deessejs/errors'; @@ -95,18 +130,20 @@ import { error, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Connection timeout')); +// Build the chain by attaching each cause to the previous. +const root = new Error('Connection timeout'); +const middle = ValidationError().from(root); +const appErr = AppError().from(middle); -// Using the causes array directly -console.log(appErr.causes.length); // 2 - -// Using the causes() helper (handles non-@deessejs/errors errors) +// Walk the chain via the causes() helper. const chain = causes(appErr); -console.log(chain.length); // 2 +console.log(chain.length); // 2 — [middle (ValidationError), root (Error)] +console.log(chain[0].name); // "ValidationError" +console.log(chain[1].message); // "Connection timeout" ``` +`causes()` accepts any value: a factory-produced `ErrorInstance`, a native `Error`, or `null`/`undefined` (returns `[]`). It also walks native `Error.cause` chains, so mixed native and factory errors compose. + The `causes()` function is particularly useful when working with errors from different sources, as it gracefully handles cases where the `causes` array might not exist. ## Why Chain Errors? diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx index 33ff3f4..b0c1e5f 100644 --- a/apps/web/content/docs/index.mdx +++ b/apps/web/content/docs/index.mdx @@ -17,26 +17,42 @@ The quickest way to understand how @deessejs/errors works is to see it in action ```ts title="index.ts" import { error, raise } from '@deessejs/errors'; +import { z } from 'zod'; -// Define your error types +// Define your error type. With a Standard Schema attached, +// every call validates the input and produces typed `fields`. const ValidationError = error({ name: 'ValidationError', - message: 'Validation failed', + fields: z.object({ field: z.string() }), + message: (d) => `Validation failed for ${d.field}`, }); -// Use the factory to create error instances -const validationErr = ValidationError({}); -console.log(validationErr.message); // "Validation failed" +// Use the factory to create error instances. +const validationErr = ValidationError({ field: 'email' }); +console.log(validationErr.message); // "Validation failed for email" +console.log(validationErr.fields.field); // "email" ``` For catching and handling errors, you can use the standard try/catch syntax or the library's `raise()` function: ```ts title="handling.ts" +import { error, raise } from '@deessejs/errors'; +import { z } from 'zod'; + +const ValidationError = error({ + name: 'ValidationError', + fields: z.object({ field: z.string() }), + message: (d) => `Validation failed for ${d.field}`, +}); + try { - raise(ValidationError({})); + raise(ValidationError({ field: 'email' })); } catch (err) { - console.log(err.name); // "ValidationError" - console.log(err instanceof Error); // true + // `err` is `unknown` in strict mode; narrow before reading. + if (err instanceof Error) { + console.log(err.name); // "ValidationError" + console.log(err instanceof Error); // true + } } ``` @@ -47,14 +63,26 @@ try { When an error occurs as a result of another error, you can preserve that relationship using the `.from()` method. This creates a chain that maintains the full history of what went wrong. ```ts title="chaining.ts" -const validationErr = ValidationError({}); -const appErr = error({ name: 'AppError' })(); +import { causes, error } from '@deessejs/errors'; +import { z } from 'zod'; + +const ValidationError = error({ + name: 'ValidationError', + fields: z.object({ field: z.string() }), + message: (d) => `Validation failed for ${d.field}`, +}); -// Chain the validation error as the cause +const AppError = error({ name: 'AppError' }); + +const validationErr = ValidationError({ field: 'email' }); +const appErr = AppError(); + +// Chain the validation error as the cause. appErr.from(validationErr); console.log(appErr.cause === validationErr); // true -console.log(appErr.causes.length); // 1 +// The full chain is reachable via the causes() helper. +console.log(causes(appErr).length); // 1 ``` ### Hierarchical Inheritance @@ -70,25 +98,34 @@ const ValidationError = error({ inherits: AppError, }); -const err = ValidationError({}); +// The no-schema factories have an empty input shape; the +// call takes no arguments. +const err = ValidationError(); -// Check the error type +// Check the error type. console.log(is(err, ValidationError)); // true console.log(is(err, AppError)); // true (through inheritance) ``` ### Message Templates -You can define errors with message templates that include field placeholders. These are replaced at runtime with the data you provide. +You can define errors with function-form `message`s that read the schema's post-transform output. The `message` function receives the validated fields, so the rendered string is type-checked against the schema. ```ts title="templates.ts" -const UserError = error<{ userId: string; reason: string }>({ +import { error } from '@deessejs/errors'; +import { z } from 'zod'; + +const UserError = error({ name: 'UserError', - message: 'User "{userId}" failed: {reason}', + fields: z.object({ + userId: z.string(), + reason: z.string(), + }), + message: (d) => `User "${d.userId}" failed: ${d.reason}`, }); const err = UserError({ userId: 'usr_123', reason: 'not found' }); -console.log(err.message); // "User "usr_123" failed: not found" +console.log(err.message); // 'User "usr_123" failed: not found' ``` ## See Also @@ -98,9 +135,9 @@ console.log(err.message); // "User "usr_123" failed: not found" Learn how to install and set up the library in your project. - Create custom error types with the error() function. + Create error types with the error() function. Chain errors together to preserve context. - \ No newline at end of file + diff --git a/apps/web/content/docs/message-templates.mdx b/apps/web/content/docs/message-templates.mdx index fe540b3..988d04c 100644 --- a/apps/web/content/docs/message-templates.mdx +++ b/apps/web/content/docs/message-templates.mdx @@ -23,7 +23,7 @@ const ValidationError = error({ }); const err = ValidationError({ field: "email", reason: "invalid format" }); -console.log(err.message); // 'Field "email" is invalid: invalid format' +console.log(err.message); // -> Field "email" is invalid: invalid format ``` The function receives the **Standard Schema output** type. After a `z.coerce.number()`, `z.brand()`, or any transform, you see the final shape. @@ -44,7 +44,7 @@ const FormatError = error({ }); const err = FormatError({ expected: "number", actual: "string" }); -console.log(err.message); // "Expected number, got string" +console.log(err.message); // -> Expected number, got string ``` ### With ArkType @@ -62,6 +62,9 @@ const ApiError = error({ }), message: (data) => `${data.method.toUpperCase()} ${data.endpoint} failed with status ${data.statusCode}`, }); + +const err = ApiError({ method: "post", endpoint: "/users", statusCode: 500 }); +console.log(err.message); // -> POST /users failed with status 500 ``` ### Modifiers via the function body @@ -69,42 +72,56 @@ const ApiError = error({ The function form replaces the legacy `:upper` / `:lower` / `:json` modifiers. Implement them inline with the JS string APIs you already know: ```ts title="modifiers.ts" -const err = error({ +import { z } from "zod"; +import { error } from "@deessejs/errors"; + +const UserCreatedError = error({ name: "UserCreatedError", fields: z.object({ userId: z.string() }), message: (data) => `Created user: ${data.userId.toUpperCase()}`, }); + +const err = UserCreatedError({ userId: "usr_abc" }); +console.log(err.message); // -> Created user: USR_ABC ``` `JSON.stringify` replaces `:json`: ```ts title="json.ts" +import { z } from "zod"; +import { error } from "@deessejs/errors"; + const DataError = error({ name: "DataError", fields: z.object({ payload: z.object({ id: z.number(), name: z.string() }) }), message: (data) => `Invalid data: ${JSON.stringify(data.payload)}`, }); + +const err = DataError({ payload: { id: 1, name: "Ada" } }); +console.log(err.message); // -> Invalid data: {"id":1,"name":"Ada"} ``` -## Legacy: string template +## Deprecated: string template (removed in 2.0.0) -The legacy string-template form (`message: "Field {field} is invalid"`) still works in the current major. It is deprecated and will be removed in the next major. New code should prefer the function form above. +The string-template form (`message: "Field {field} is invalid"`) was deprecated in 1.4.0 and **removed in 2.0.0** for the schema overload. A factory declared with a string `message` is no longer accepted alongside a schema; the schema overload requires a function-form `message`. The legacy no-schema form (no `fields` schema) still accepts a string `message`, and renders it without placeholder substitution: ```ts title="legacy.ts" +import { error } from "@deessejs/errors"; + const ValidationError = error({ name: "ValidationError", message: "Field {field} is invalid", }); -const err = ValidationError({ field: "email" }); -console.log(err.message); // "Field email is invalid" +const err = ValidationError(); +console.log(err.message); // -> Field {field} is invalid ``` Behavior summary: -- Placeholders without a value are left as-is in the output, making missing data easy to spot. -- Modifier suffixes `:upper`, `:lower`, `:json` are still parsed and applied on top of the raw input. -- Schema validation, when supplied, runs before template rendering; failed validation throws `ArgsValidationError`. +- The function form is the only path that produces type-checked, post-transform fields. New code should use the function form. +- For no-schema factories, a string `message` is rendered verbatim. The function form is the recommended path. +- Schema validation, when supplied, runs before rendering; failed validation throws `ArgsValidationError`. ## See Also diff --git a/apps/web/content/docs/multiple-inheritance.mdx b/apps/web/content/docs/multiple-inheritance.mdx index 54ae026..e45e4f3 100644 --- a/apps/web/content/docs/multiple-inheritance.mdx +++ b/apps/web/content/docs/multiple-inheritance.mdx @@ -29,6 +29,8 @@ Now this error belongs to both categories in the hierarchy. When an error inherits from multiple parents, `is()` returns true for any of its parents: ```ts title="checking.ts" +import { error, is } from '@deessejs/errors'; + const NetworkError = error({ name: 'NetworkError' }); const StorageError = error({ name: 'StorageError' }); @@ -37,11 +39,13 @@ const NetworkStorageError = error({ inherits: [NetworkError, StorageError], }); -const err = NetworkStorageError({}); +// The no-schema factories have an empty input shape; the call +// takes no arguments. +const err = NetworkStorageError(); -console.log(is(err, NetworkStorageError)); // true -console.log(is(err, NetworkError)); // true -console.log(is(err, StorageError)); // true +console.log(is(err, NetworkStorageError)); // -> true +console.log(is(err, NetworkError)); // -> true +console.log(is(err, StorageError)); // -> true ``` This flexibility lets you categorize errors from multiple angles. @@ -76,7 +80,7 @@ function handleStorageIssue(err: unknown) { } } -const cacheErr = CacheError({}); +const cacheErr = CacheError(); handleNetworkIssue(cacheErr); // Logs (through NetworkError inheritance) handleStorageIssue(cacheErr); // Logs (through StorageError inheritance) @@ -86,33 +90,50 @@ This pattern is useful for middleware and infrastructure code that needs to resp ## Combining with Specific Error Data -Multiple inheritance works seamlessly with typed fields: +Multiple inheritance works with schema-bearing factories. Each parent declares its own schema; the leaf composes a schema whose output is assignable to **every** parent's output: ```ts title="with-fields.ts" +import { z } from 'zod'; import { error, is } from '@deessejs/errors'; -const NetworkError = error<{ endpoint: string }>({ +const NetworkError = error({ name: 'NetworkError', - message: 'Network request to {endpoint} failed', + fields: z.object({ endpoint: z.string() }), + message: (d) => `Network request to ${d.endpoint} failed`, }); -const StorageError = error<{ path: string }>({ +const StorageError = error({ name: 'StorageError', - message: 'Storage operation at {path} failed', + fields: z.object({ path: z.string() }), + message: (d) => `Storage operation at ${d.path} failed`, }); -const CacheError = error<{ key: string; operation: string }>({ +// The leaf's output must be assignable to every parent's output. +// The leaf's `fields` carries its own specific data, plus the +// keys the parents require. +const CacheError = error({ name: 'CacheError', + fields: z.object({ + endpoint: z.string(), + path: z.string(), + key: z.string(), + operation: z.string(), + }), + message: (d) => + `Cache ${d.operation} for key ${d.key} at ${d.endpoint} → ${d.path} failed`, inherits: [NetworkError, StorageError], - message: 'Cache {operation} for key {key} failed', }); -// The error carries its own specific data -const err = CacheError({ key: 'user:123', operation: 'read' }); +const err = CacheError({ + endpoint: '/api', + path: '/var/cache', + key: 'user:123', + operation: 'read', +}); console.log(err.fields.key); // "user:123" -console.log(err.message); // "Cache read for key user:123 failed" +console.log(err.message); // "Cache read for key user:123 at /api → /var/cache failed" -// But also inherits type checking for parents +// The leaf is also recognized as an instance of every parent. console.log(is(err, NetworkError)); // true console.log(is(err, StorageError)); // true ``` @@ -129,6 +150,65 @@ Multiple inheritance is appropriate when: Avoid using multiple inheritance just because you can. Prefer single inheritance for most errors, and only use multiple inheritance when the error genuinely belongs to multiple categories. +## Schema Validation Across Multiple Parents + +Multiple inheritance declares a static type-level relationship: the leaf's `InferOutput` must be assignable to **every** parent's `InferOutput`. The constraint is checked at the `error()` definition site — a violation is a TypeScript error, not a runtime throw. Each factory still runs its own schema; the parents' schemas do not validate the child's data. + +```ts title="composition.ts" +import { z } from 'zod'; +import { error } from '@deessejs/errors'; + +const NetworkError = error({ + name: 'NetworkError', + fields: z.object({ endpoint: z.string() }), + message: (d) => d.endpoint, +}); +const StorageError = error({ + name: 'StorageError', + fields: z.object({ path: z.string() }), + message: (d) => d.path, +}); + +// The consumer composes the leaf's schema to satisfy both +// parents. TypeScript verifies that the leaf's output is +// assignable to NetworkError's AND StorageError's. +const CacheError = error({ + name: 'CacheError', + fields: z.object({ endpoint: z.string(), path: z.string() }), + message: (d) => `${d.endpoint} → ${d.path}`, + inherits: [NetworkError, StorageError], +}); +``` + +If the leaf's output is not assignable to one of the parents, the call site errors: + +```ts title="sibling-incompatible.ts" +import { z } from 'zod'; +import { error } from '@deessejs/errors'; + +const A = error({ + name: 'A', + fields: z.object({ a: z.string() }), + message: (d) => d.a, +}); +const B = error({ + name: 'B', + fields: z.object({ b: z.number() }), + message: (d) => String(d.b), +}); + +// Type error: the leaf is missing `b` for B. +error({ + name: 'C', + fields: z.object({ a: z.string() }), + message: (d) => d.a, + // @ts-expect-error — child is missing b for B + inherits: [A, B], +}); +``` + +Diamond inheritance (`A ← B`, `A ← C`, `D.inherits: [B, C]`) is recognized for `is()` walks but does not cause any cascade. The leaf's schema is the only one that runs. + ## See Also @@ -138,4 +218,4 @@ Avoid using multiple inheritance just because you can. Prefer single inheritance Check error types including inherited ones. - \ No newline at end of file + diff --git a/apps/web/content/docs/recipes.mdx b/apps/web/content/docs/recipes.mdx index 3535500..ee9a21b 100644 --- a/apps/web/content/docs/recipes.mdx +++ b/apps/web/content/docs/recipes.mdx @@ -7,7 +7,7 @@ This page collects common patterns and practical examples for using @deessejs/er ## Validation Errors -Validation errors are one of the most common error types. They typically include the field that failed validation and a reason for the failure. +Validation errors are one of the most common error types. They typically include the field that failed validation and a reason for the failure. With a Standard Schema attached to the factory, the field shape is enforced at every call. ```ts title="validation.ts" import { error, raise } from '@deessejs/errors'; @@ -19,10 +19,17 @@ const UserSchema = z.object({ age: z.number().min(0).max(150), }); -// Create the error factory -const ValidationError = error<{ field: string; reason: string; value?: unknown }>({ +// Create the error factory. The schema's `InferOutput` is the +// factory's `fields` type; the `message` function receives +// the post-transform output. +const ValidationError = error({ name: 'ValidationError', - message: 'Validation failed for field "{field}"', + fields: z.object({ + field: z.string(), + reason: z.string(), + value: z.unknown().optional(), + }), + message: (data) => `Validation failed for field "${data.field}": ${data.reason}`, }); // Validation function @@ -36,7 +43,7 @@ function validateUser(data: unknown) { field: issue.path.join('.'), reason: issue.message, value: (result.error as { value?: unknown }).value, - }) + }), ); } @@ -48,18 +55,20 @@ When validation fails, you get an error with specific information about which fi ## Network Errors -Network errors benefit from including the endpoint and any relevant request data. +Network errors benefit from including the endpoint and any relevant request data. The schema's input is the call site shape; consumers can supply the response status without a separate field for it. ```ts title="network.ts" import { error, raise } from '@deessejs/errors'; +import { z } from 'zod'; -const NetworkError = error<{ - endpoint: string; - method?: string; - statusCode?: number; -}>({ +const NetworkError = error({ name: 'NetworkError', - message: 'Request to {endpoint} failed', + fields: z.object({ + endpoint: z.string(), + method: z.string().optional(), + statusCode: z.number().optional(), + }), + message: (data) => `Request to ${data.endpoint} failed`, }); async function fetchWithError(url: string) { @@ -70,7 +79,7 @@ async function fetchWithError(url: string) { NetworkError({ endpoint: url, statusCode: response.status, - }) + }), ); } @@ -82,32 +91,36 @@ You can extend this pattern to include request headers, body, or timing informat ## Database Errors -Database errors typically need the query that failed and any relevant identifiers. +Database errors typically need the query that failed and any relevant identifiers. The factory's schema keeps the input shape and the runtime output type in lockstep. ```ts title="database.ts" -import { error, raise } from '@deessejs/errors'; +import { error } from '@deessejs/errors'; +import { z } from 'zod'; -const DatabaseError = error<{ - operation: string; - table?: string; - query?: string; -}>({ +const DatabaseError = error({ name: 'DatabaseError', - message: 'Database {operation} failed', + fields: z.object({ + operation: z.string(), + table: z.string().optional(), + query: z.string().optional(), + }), + message: (data) => `Database ${data.operation} failed`, }); // Wrap database operations -async function executeQuery(query: string, table: string) { +async function executeQuery(db: { query: (q: string) => Promise }, query: string, table: string) { try { return await db.query(query); } catch (err) { - raise( - DatabaseError({ - operation: 'query', - table, - query, - }).from(err as Error) - ); + // `DatabaseError(...)` returns a fresh instance, then + // `.from(err)` chains the cause. `.from()` is `this`-returning. + // Re-throw so callers see the failure rather than a + // resolved promise with an error value. + throw DatabaseError({ + operation: 'query', + table, + query, + }).from(err instanceof Error ? err : new Error(String(err))); } } ``` @@ -116,13 +129,29 @@ Chaining the original error preserves the full context of what went wrong. ## Error Handling Middleware -Create a reusable error handler that processes errors based on their type. +Create a reusable error handler that processes errors based on their type. Each `is()` check narrows the error to a specific factory's `InferOutput`, so `err.fields.field`, `err.fields.endpoint`, etc. are all type-checked. ```ts title="handler.ts" -import { error, raise, is, causes } from '@deessejs/errors'; -import { ValidationError, NetworkError, DatabaseError } from './errors'; +import { error, is, causes } from '@deessejs/errors'; +import { z } from 'zod'; + +const ValidationError = error({ + name: 'ValidationError', + fields: z.object({ field: z.string() }), + message: (d) => `Invalid input: ${d.field}`, +}); + +const NetworkError = error({ + name: 'NetworkError', + message: () => 'External service unavailable', +}); -export function handleApiError(err: unknown) { +const DatabaseError = error({ + name: 'DatabaseError', + message: () => 'Database operation failed', +}); + +function handleApiError(err: unknown) { // Log the full error chain console.error('Error chain:', causes(err).map((e) => e.message)); @@ -159,32 +188,37 @@ This pattern centralizes error handling logic and ensures consistent responses. ## Service Layer Error Wrapping -When building services that call other services, wrap errors to add context while preserving the cause chain. +When building services that call other services, wrap errors to add context while preserving the cause chain. `.from()` replaces the previous direct cause; build the chain by attaching each layer's cause to the previous one. ```ts title="service.ts" -import { error, raise } from '@deessejs/errors'; -import { NetworkError, UserNotFoundError } from './errors'; +import { error } from '@deessejs/errors'; +import { z } from 'zod'; const AppError = error({ name: 'AppError' }); +const UserNotFoundError = error({ + name: 'UserNotFoundError', + fields: z.object({ userId: z.string() }), + message: (d) => `User ${d.userId} not found`, +}); + class UserService { - async getUser(userId: string) { + async getUser(userId: string, fetchUser: (id: string) => Promise) { try { - const user = await this.fetchUser(userId); + const user = await fetchUser(userId); if (!user) { - raise(UserNotFoundError({ userId })); + // `.raise()` is the idiomatic throw; here we use + // throw since we're in a class method. + throw UserNotFoundError({ userId }); } return user; } catch (err) { - // Wrap any error from lower layers - raise(AppError({}).from(err as Error)); + // Wrap any error from lower layers. `err` is `unknown` + // in strict mode; check before .from(err). + const cause = err instanceof Error ? err : new Error(String(err)); + throw AppError().from(cause); } } - - private async fetchUser(userId: string) { - // Implementation - return null; - } } ``` @@ -192,10 +226,11 @@ The caller gets both the context of the current operation and the underlying cau ## Hierarchical Error Categories -Create a hierarchy that lets you handle errors at different levels of granularity. +Create a hierarchy that lets you handle errors at different levels of granularity. `is()` walks the factory's `inherits` graph, so a single check can match a leaf and any of its ancestors. ```ts title="hierarchy.ts" import { error, is } from '@deessejs/errors'; +import { z } from 'zod'; // Top level const AppError = error({ name: 'AppError' }); @@ -204,26 +239,32 @@ const AppError = error({ name: 'AppError' }); const UserError = error({ name: 'UserError', inherits: AppError }); const ProductError = error({ name: 'ProductError', inherits: AppError }); -// Specific errors -const UserNotFoundError = error<{ userId: string }>({ +// Specific errors. Each leaf carries its own schema; the +// InferOutput stays scoped to that factory. +const UserNotFoundError = error({ name: 'UserNotFoundError', + fields: z.object({ userId: z.string() }), + message: (d) => `User ${d.userId} not found`, inherits: UserError, }); -const UserPermissionError = error<{ userId: string; action: string }>({ +const UserPermissionError = error({ name: 'UserPermissionError', + fields: z.object({ userId: z.string(), action: z.string() }), + message: (d) => `User ${d.userId} cannot ${d.action}`, inherits: UserError, }); function handleError(err: unknown) { if (is(err, AppError)) { - // Catches everything + // Catches everything in the AppError family. } if (is(err, UserError)) { - // Catches UserNotFoundError, UserPermissionError + // Catches UserNotFoundError, UserPermissionError. } if (is(err, UserNotFoundError)) { - // Catches only user not found errors + // Catches only user not found errors; `err.fields.userId` + // is typed as `string`. } } ``` @@ -245,4 +286,4 @@ This lets you write both broad handlers and specific ones depending on the situa Preserve error context. - \ No newline at end of file + diff --git a/apps/web/content/docs/single-inheritance.mdx b/apps/web/content/docs/single-inheritance.mdx index 2f765ad..9e93115 100644 --- a/apps/web/content/docs/single-inheritance.mdx +++ b/apps/web/content/docs/single-inheritance.mdx @@ -29,16 +29,20 @@ Now `ValidationError` is a child of `AppError` in the hierarchy. You can verify When an error inherits from another, `is()` returns true for both the error itself and all its ancestors: ```ts title="checking.ts" +import { error, is } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError', inherits: AppError, }); -const err = ValidationError({ field: 'email' }); +// The no-schema factories have an empty input shape; the call +// takes no arguments. +const err = ValidationError(); -console.log(is(err, ValidationError)); // true -console.log(is(err, AppError)); // true (through inheritance) +console.log(is(err, ValidationError)); // -> true +console.log(is(err, AppError)); // -> true ``` This is powerful for error handling — you can write a catch block for `AppError` and it will catch all errors that inherit from it, including `ValidationError`, `DatabaseError`, and any other descendants. @@ -73,8 +77,8 @@ function handleError(err: unknown) { } } -handleError(ValidationError({ field: 'email' })); // Logs -handleError(NetworkError({ url: '/api' })); // Logs +handleError(ValidationError()); // Logs +handleError(NetworkError()); // Logs ``` This pattern lets you handle errors at different levels of granularity depending on your needs. @@ -84,19 +88,21 @@ This pattern lets you handle errors at different levels of granularity depending You can combine inheritance with message templates for powerful error definitions: ```ts title="with-message.ts" -import { error, is } from '@deessejs/errors'; +import { z } from "zod"; +import { error, is } from "@deessejs/errors"; -const AppError = error({ name: 'AppError' }); +const AppError = error({ name: "AppError" }); -const ValidationError = error<{ field: string }>({ - name: 'ValidationError', +const ValidationError = error({ + name: "ValidationError", inherits: AppError, - message: 'Field "{field}" is invalid', + fields: z.object({ field: z.string() }), + message: (d) => `Field "${d.field}" is invalid`, }); -const err = ValidationError({ field: 'email' }); -console.log(err.message); // "Field "email" is invalid" -console.log(is(err, AppError)); // true +const err = ValidationError({ field: "email" }); +console.log(err.message); // -> Field "email" is invalid +console.log(is(err, AppError)); // -> true ``` ## Accessing Parent Information @@ -104,17 +110,120 @@ console.log(is(err, AppError)); // true The `inherits` property on the error factory lets you access parent types programmatically: ```ts title="parents.ts" -const AppError = error({ name: 'AppError' }); +import { error } from "@deessejs/errors"; + +const AppError = error({ name: "AppError" }); const ValidationError = error({ - name: 'ValidationError', + name: "ValidationError", inherits: AppError, }); -console.log(ValidationError.inherits === AppError); // true +console.log(ValidationError.inherits === AppError); // -> true ``` This is useful for building introspection tools or generating documentation automatically. +## Schema Validation Across the Chain + +Inheritance declares a static type-level relationship, not a runtime cascade. Each factory runs its own schema; the parent schema does **not** validate the child's data at instantiation. The `is()` walk continues to recognize the child as an instance of the parent for type-narrowing purposes, but `instance.fields` reflects only the leaf's schema output. + +```ts title="composition.ts" +import { error } from '@deessejs/errors'; +import { z } from 'zod'; + +// Parent carries a schema. +const Parent = error({ + name: 'Parent', + fields: z.object({ id: z.string() }), + message: (d) => d.id, +}); + +// The consumer composes the leaf's schema explicitly. The library +// does not merge two schemas for you. +const Leaf = error({ + name: 'Leaf', + fields: z.object({ id: z.string(), extra: z.string() }), + message: (d) => `${d.id}-${d.extra}`, + inherits: Parent, +}); +``` + +The TypeScript constraint enforces that `InferOutput` is assignable to `InferOutput`. The child may add fields but must not drop or change the parent's required ones: + +```ts title="incompatible-inheritance.ts" +import { z } from "zod"; +import { error } from "@deessejs/errors"; + +// Type error: { n: string } is not assignable to { n: number }. +const Parent = error({ + name: "Parent", + fields: z.object({ n: z.coerce.number() }), + message: (d) => String(d.n), +}); + +error({ + name: "Leaf", + fields: z.object({ n: z.string() }), + message: (d) => d.n, + // @ts-expect-error — { n: string } is not assignable to Parent's + // output { n: number } + inherits: Parent, +}); +``` + +The constraint catches every structural mismatch the previous runtime gates tried to enforce — object shape differences, literal value differences, array element differences, and nested structural differences — at the type level, before any code runs. + +## Composing a Parent's Schema + +To add fields to a parent's schema in the child, use your validator's primitives. The library does not pick a winner; the choice is yours. + +```ts title="compose-zod.ts" +import { z } from "zod"; +import { error } from "@deessejs/errors"; + +const Parent = error({ + name: "Parent", + fields: z.object({ registry: z.string() }), + message: (d) => d.registry, +}); + +const TemplateNotFound = error({ + name: "TemplateNotFound", + fields: z.object({ registry: z.string(), slug: z.string() }), + message: (d) => `${d.slug} not found in ${d.registry}`, + inherits: Parent, +}); +``` + +For Valibot, declare the parent schema separately and spread its entries into the leaf's schema. The factory exposes the schema via `.schema` (not `.fields` — that's the instance slot): + +```ts title="compose-valibot.ts" +import * as v from 'valibot'; +import { error } from '@deessejs/errors'; + +// Declare the parent schema as a reusable variable. The +// factory stores it on `.schema`; we don't read .schema +// back, we use the local reference. +const parentSchema = v.object({ registry: v.string() }); + +const Parent = error({ + name: 'Parent', + fields: parentSchema, + message: (d) => d.registry, +}); + +const TemplateNotFound = error({ + name: 'TemplateNotFound', + fields: v.object({ ...parentSchema.entries, slug: v.string() }), + message: (d) => `${d.slug} not found in ${d.registry}`, + inherits: Parent, +}); +``` + +For ArkType, use `pipe`, `and`, or `merge`. The point is the same: the consumer composes the schema, the library runs it on the input, and `inherits` declares the type-level relationship. + +The factory's `inherits` slot is frozen at construction time. The caller's array is **not** mutated in place — surprising the consumer with a side effect on a shared list was a documented footgun. The factory builds a single frozen snapshot once, and both the factory's metadata and every produced instance's `inherits` share the same reference. The classification invariant is preserved across caller-side mutations on the original list. + ## See Also @@ -127,4 +236,4 @@ This is useful for building introspection tools or generating documentation auto See the complete picture of how errors work together. - \ No newline at end of file + diff --git a/apps/web/content/docs/type-checking.mdx b/apps/web/content/docs/type-checking.mdx index 580258f..a85cc1e 100644 --- a/apps/web/content/docs/type-checking.mdx +++ b/apps/web/content/docs/type-checking.mdx @@ -18,7 +18,9 @@ const ValidationError = error({ inherits: AppError, }); -const err = ValidationError({ field: 'email' }); +// The no-schema factories have an empty input shape; the call +// takes no arguments. +const err = ValidationError(); console.log(is(err, ValidationError)); // true console.log(is(err, AppError)); // true (through inheritance) @@ -83,10 +85,13 @@ This lets you mix @deessejs/errors with standard JavaScript error handling seaml When `is()` returns true, TypeScript knows the error is of that specific type. This enables type-safe access to fields: ```ts title="type-safety.ts" +import { z } from "zod"; import { error, is } from '@deessejs/errors'; -const ValidationError = error<{ field: string; reason: string }>({ +const ValidationError = error({ name: 'ValidationError', + fields: z.object({ field: z.string(), reason: z.string() }), + message: (d) => `${d.field}: ${d.reason}`, }); function processError(err: unknown) { @@ -141,14 +146,15 @@ const ValidationError = error({ inherits: AppError, }); -const err = ValidationError({}); +const err = ValidationError(); // This works -console.log(is(err, ValidationError)); // true -console.log(is(err, AppError)); // true +console.log(is(err, ValidationError)); // -> true +console.log(is(err, AppError)); // -> true -// This doesn't work for @deessejs/errors -console.log(err instanceof ValidationError); // false +// `instanceof` does not work because @deessejs/errors +// factories are functions, not classes. Use `is()` instead. +console.log(err instanceof Error); // -> true ``` Always use `is()` instead of `instanceof` when working with @deessejs/errors errors. diff --git a/packages/errors/README.md b/packages/errors/README.md index 13b825e..79b2c28 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -36,19 +36,28 @@ npm install @deessejs/errors ## Usage ```typescript +import { z } from 'zod'; import { error, raise, is, causes } from '@deessejs/errors'; -// Define an error factory with a templated message +// Define a factory with a Standard Schema. The `message` is a +// function that receives the schema's *output* (post-transform). +// The factory's call signature requires the schema's *input*, +// so callers can supply the validated shape directly. const ValidationError = error({ name: 'ValidationError', - message: 'Field "{field}" is invalid: {reason}', + fields: z.object({ + field: z.string(), + reason: z.string(), + }), + message: (data) => `Field "${data.field}" is invalid: ${data.reason}`, }); -// Construct a typed error +// Construct a typed error. The schema validates the input. const err = ValidationError({ field: 'email', reason: 'invalid format' }); // err.message === 'Field "email" is invalid: invalid format' -// Chain a cause +// Chain a cause. .from() replaces the previous cause; the +// previous cause is reachable through its own .cause link. const cause = error({ name: 'NetworkError' })(); err.from(cause); @@ -70,11 +79,11 @@ causes(err); // [cause] ## Engine compatibility -| Runtime | Required | -| ---------- | -------------------- | -| Node.js | `>=22.14.0` | -| pnpm | `10` for development | -| TypeScript | `5.x` | +| Runtime | Required | +| ---------- | ---------------------------------------------------------------- | +| Node.js | `>=22.14.0` | +| pnpm | `10` for development | +| TypeScript | `5.4` or later (the public `error()` overloads use `NoInfer`) | ESM-only. Consumers using a CJS resolver need to use dynamic `import()` or migrate to ESM. diff --git a/packages/errors/package.json b/packages/errors/package.json index b3bf4f5..69cd9cb 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -31,6 +31,9 @@ "test:run": "vitest run", "build": "tsc -p tsconfig.build.json", "type-check": "tsc --noEmit", + "type-check:test": "tsc --noEmit -p tsconfig.test.json", + "test:consumer": "pnpm build && node tests/consumer-from-dist.mjs", + "test:docs": "pnpm build && node tests/docs-examples-typecheck.mjs", "lint": "eslint src/" }, "keywords": [ diff --git a/packages/errors/src/causes/index.ts b/packages/errors/src/causes/index.ts index b9d933f..8049fa7 100644 --- a/packages/errors/src/causes/index.ts +++ b/packages/errors/src/causes/index.ts @@ -3,17 +3,24 @@ */ /** - * Returns all causes in the error chain, from most recent to root cause. + * Walks the cause chain of an error, returning the chain from the + * immediate cause down to the root. * - * The function uses a structural guard (`'causes' in error && Array.isArray(error.causes)`) - * rather than a type cast. This is rule 0004 in operational form: the - * guard is named, the scenario it covers is named, and the input can be - * `unknown` without an `as ErrorInstance` cast at the call site. + * The chain is built by following `.cause` (with structural guards + * that accept any value, including native `Error.cause` and + * `ErrorInstance.cause`). Cycle detection via a `Set` ensures that + * a malformed cycle does not hang the process. * - * @param error - The error to get causes from (any value; `null` and - * `undefined` return `[]`) - * @returns Array of errors in the cause chain, ordered newest to - * oldest. Returns `[]` when the input does not carry a `causes` array. + * The returned array is a *new* array; mutations do not affect the + * underlying error. The `causes: Error[]` field that older versions + * of this package attached to each instance has been removed in + * Phase 4b. Walk the chain instead. + * + * @param error - The error to get causes from. `null` and + * `undefined` return `[]`. Native `Error` instances are accepted + * alongside `ErrorInstance`. + * @returns Array of errors in the cause chain, ordered immediate + * cause first, root cause last. * * @example * ```typescript @@ -32,24 +39,33 @@ * .from(new NetworkError('Connection failed')) * .from(new Error('DNS lookup failed')); * - * // causes(err) returns newest-to-oldest: [NetworkError, Error] - * // (err.cause is NetworkError, err.cause.cause is Error) + * // causes(err) returns [NetworkError, Error] + * // (err.cause is NetworkError, NetworkError.cause is Error) * ``` */ const causes = (error: unknown): Error[] => { - if (error == null) { + if (error == null || typeof error !== 'object') { return []; } - if (typeof error !== 'object') { - return []; - } + const result: Error[] = []; + const seen = new Set(); - if (!('causes' in error) || !Array.isArray(error.causes)) { - return []; + // Only follow `.cause` if it is itself an Error (or ErrorInstance). + // A primitive or non-Error object as `cause` is a malformed input + // (the runtime or upstream set it incorrectly); we stop the walk + // rather than include the malformed value in the result. + const rawCause = (error as { cause?: unknown }).cause; + let current: Error | null = rawCause !== null && rawCause instanceof Error ? rawCause : null; + + while (current !== null && !seen.has(current)) { + seen.add(current); + result.push(current); + const nextRaw = (current as { cause?: unknown }).cause; + current = nextRaw !== null && nextRaw instanceof Error ? nextRaw : null; } - return error.causes; + return result; }; export { causes }; diff --git a/packages/errors/src/error/capture.ts b/packages/errors/src/error/capture.ts index 0175200..e128aa3 100644 --- a/packages/errors/src/error/capture.ts +++ b/packages/errors/src/error/capture.ts @@ -1,40 +1,82 @@ /** * Stack trace capture utilities. - */ - -import { STACK_FRAME_PATTERN } from './constants.js'; - -/** - * Captures the current stack trace, cleaning up internal frames. * - * Uses Error.captureStackTrace in V8 environments for better performance. - * Falls back to string manipulation in other engines. + * Uses V8's `Error.captureStackTrace` when available (Node.js, Chrome, + * Edge, modern browsers). The optional second argument names a + * constructor above which frames are excluded — we pass `error` so + * callers see only the call site that invoked the factory, not + * the factory's own frame. In non-V8 engines, falls back to + * `new Error().stack` and post-processes the string to drop + * vendor-internal frames. * * @internal */ -const captureStack = (message: string): string => { - const stack = new Error().stack || ''; - const lines = stack.split('\n'); - const cleanedLines: string[] = [`Error: ${message}`]; - // Find start index (skip "Error: message" line) - let startIndex = 0; - for (let i = 0; i < lines.length; i = i + 1) { - if (STACK_FRAME_PATTERN.test(lines[i])) { - startIndex = i; - break; - } +// `Error.captureStackTrace` is a V8 extension, not in lib.dom or +// lib.es2022. We feature-detect it at module load time and cast +// through `unknown` to keep the call site terse. The `Function` +// type for the `exclude` parameter is intentional: V8's spec +// accepts any callable, and `Function` is the only single-token +// name in lib.es2022 that describes it without enumerating every +// conceivable signature. +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +type AnyFunction = Function; +const errorWithCapture = Error as unknown as { + captureStackTrace?: (target: object, exclude?: AnyFunction) => void; +}; + +const hasV8Capture = typeof errorWithCapture.captureStackTrace === 'function'; + +const captureStackV8 = (message: string, exclude: AnyFunction): string => { + // V8's captureStackTrace mutates the target in place to set + // `.stack`. The exclude argument drops frames above it in the + // call stack. We build a throwaway holder, capture into it, and + // return the resulting string. + const holder: { stack?: string } = {}; + errorWithCapture.captureStackTrace!(holder, exclude); + const raw = holder.stack ?? ''; + // V8's first line is the error name + message; replace it with + // our preferred header. The rest of the stack is already filtered + // by V8 itself. + const lines = raw.split('\n'); + if (lines.length === 0) { + return `Error: ${message}`; } + return [`Error: ${message}`, ...lines.slice(1)].join('\n'); +}; - // Filter internal frames - for (let i = startIndex; i < lines.length; i = i + 1) { - const line = lines[i]; +const captureStackFallback = (message: string): string => { + // Non-V8 engines: build a stack via the standard `Error` + // constructor and post-process the string to drop vendor + // frames. The same heuristic as before, kept for parity with + // the pre-Phase-11 behavior on engines that do not implement + // V8's captureStackTrace. + const stack = new Error(message).stack ?? ''; + const lines = stack.split('\n'); + const cleanedLines: string[] = [`Error: ${message}`]; + for (const line of lines) { if (line.includes('node_modules/@deessejs')) continue; if (line.includes('__vite')) continue; + if (line.trim().length === 0) continue; cleanedLines.push(line); } - return cleanedLines.join('\n'); }; +/** + * Captures the current stack trace as a string. + * + * The optional second argument is a constructor function whose + * frame (and above) should be excluded from the trace. Pass + * `error` to drop the factory's own frame. + * + * @internal + */ +const captureStack = (message: string, exclude?: AnyFunction): string => { + if (hasV8Capture && exclude) { + return captureStackV8(message, exclude); + } + return captureStackFallback(message); +}; + export { captureStack }; diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index c65d42f..28f12f9 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -6,7 +6,15 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; -import type { ErrorFactory, ErrorInstance } from './types.js'; +import type { + AnyErrorFactory, + ErrorFactory, + ErrorInstance, + IsObjectOutput, + ParentFor, + SchemaErrorFactory, +} from './types.js'; +import { acceptsFields } from './types.js'; import { captureStack } from './capture.js'; import { formatTemplate, hasTemplatePlaceholders } from './format.js'; @@ -16,6 +24,9 @@ import { formatTemplate, hasTemplatePlaceholders } from './format.js'; // The package ships pure ESM and intentionally does not depend on `@types/node` // at runtime. For this single use site we declare the narrow subset we need. +// `process` is read only inside `warnLegacy`, which guards the read with +// `typeof process !== 'undefined'` to survive environments where the +// identifier is not present (e.g. browser bundles without a polyfill). declare const process: | { env: Record; @@ -28,29 +39,90 @@ declare const process: /** * Symbol used to identify factory-created errors. - * Stored on the error instance to enable reliable instanceof checks. + * Stored on the error instance to enable reliable recognition by + * `is()`. Uses `Symbol.for` so the same key resolves across + * realms/bundles. * * @internal */ const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory'); +/** + * Private registry of every `ErrorInstance` produced by a factory + * in this package load. `is()` consults the registry to reject + * foreign objects that imitate the marker shape. + * + * The audit found that method-presence checks (`.from`, `.addNote`) + * are not a sound gate: a hand-rolled `Error` subclass with the + * right methods but no real `.fields` slips through. The registry + * is the **authoritative** identity: a candidate is a real + * `ErrorInstance` only if this package added it. + * + * The `WeakSet` keeps the memory profile clean: when the instance + * becomes unreachable, the entry is GC'd automatically. The set + * itself is module-private, so consumers cannot add to it from + * outside the package. + * + * **The registry is per-package-load.** An instance created by a + * second copy of `@deessejs/errors` (a duplicate in `node_modules`, + * a separate bundle, a CommonJS/ESM dual load, two copies running + * in a micro-frontend) is registered in *that* copy's `WeakSet`, + * not ours. Holding a direct reference to its factory is not + * sufficient: the marker slot is read and matches, but the + * registry check fails, and `is()` returns false. To recognize + * cross-load instances, the consumer must call `is()` from the + * same load that produced the instance. This is a deliberate + * trade-off: the marker alone is spoofable, the registry alone is + * not portable, and combining them makes forgeries impossible + * within a load at the cost of cross-load compatibility. + * + * @internal + */ +const INSTANCE_REGISTRY: WeakSet = new WeakSet(); + +/** + * Mark an `ErrorInstance` as registered with this package. Called + * exactly once, at construction time, by `buildErrorInstance`. + * + * @internal + */ +const registerInstance = (instance: object): void => { + INSTANCE_REGISTRY.add(instance); +}; + +/** + * Check whether a candidate is a registered instance produced by + * this package. The check is O(1) and never throws: a foreign + * object, a primitive, or `null` simply returns false. + * + * @internal + */ +const isRegisteredInstance = (candidate: unknown): candidate is object => { + if (candidate === null || typeof candidate !== 'object') return false; + return INSTANCE_REGISTRY.has(candidate); +}; + // ============================================================================ // Deprecation tracking // ============================================================================ /** - * Tracks call sites that still use the legacy message-template form. The - * runtime emits a single warning per site so consumers can find and migrate - * their `error({ name, message: 'string' })` calls. + * Tracks call sites that still use the legacy string-template form + * (`error({ name, message: 'Hello {name}' })`). The runtime emits a + * single warning per site so consumers can find and migrate. * * Set `process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES = '1'` to silence. * + * The warning is now scoped to the *template* form (string with + * `{field}` placeholders) only. A plain string `message` (no + * placeholders) and a function-form `message` do not warn. + * * @internal */ const warnedLegacyCallSites = new Set(); function warnLegacy(callSite: string): void { - const legacyGate = (process as { env?: Record } | undefined)?.env - ?.DEESSEJS_ERRORS_LEGACY_TEMPLATES; + if (typeof process === 'undefined') return; + const legacyGate = process.env?.DEESSEJS_ERRORS_LEGACY_TEMPLATES; if (legacyGate === '1') return; if (warnedLegacyCallSites.has(callSite)) return; warnedLegacyCallSites.add(callSite); @@ -67,47 +139,142 @@ function warnLegacy(callSite: string): void { // ============================================================================ /** - * Run a `StandardSchemaV1` validator and return either the validated output - * or the failure result. Mirrors the shape documented in `@standard-schema/spec`. - * - * The output is typed as `unknown` here; the caller (which knows the - * concrete `T`) is responsible for the cast. + * Run a `StandardSchemaV1` validator and return either the validated + * output or the failure result. Reuses `StandardSchemaV1.Result` and + * `StandardSchemaV1.Issue` from the spec rather than re-typing them. * * @internal */ function runSchema( schema: StandardSchemaV1, - input: unknown -): { ok: true; value: unknown } | { ok: false; issues: ReadonlyArray } { - const handle = schema; - const result = handle['~standard'].validate(input) as unknown; + input: unknown, + factoryName: string +): { ok: true; value: unknown } | { ok: false; issues: ReadonlyArray } { + const result = schema['~standard'].validate(input); if (result && typeof (result as Promise).then === 'function') { + // The schema returned a Promise, but error() is synchronous. We + // refuse to wait. The Promise itself is still in flight; attach a + // no-op catch so its rejection is contained. + (result as Promise).catch(() => undefined); throw new ArgsValidationError( - `Async schemas are not supported in \`error({...})\`. ` + - `Use \`schema\` directly (await) before instantiating.`, + factoryName, [{ message: 'Async validation not supported in error()' }], - handle['~standard'].vendor ?? 'unknown' + schema['~standard'].vendor ?? 'unknown' ); } - const r = result as { value?: unknown; issues?: unknown }; + const r = result as { value?: unknown; issues?: ReadonlyArray }; if (r && Array.isArray(r.issues)) { - return { ok: false, issues: r.issues as ReadonlyArray }; + return { ok: false, issues: r.issues }; } - return { ok: true, value: r.value as unknown }; + return { ok: true, value: r.value }; +} + +/** + * Runtime guard: a schema's validated output must be a non-null, + * non-array object. Schemas whose transformation returns a primitive, + * null, or array violate the public contract + * (`instance.fields: Record`); the consumer's + * downstream code would crash. + * + * The type-level gate `IsObjectOutput` rejects the same case at + * compile time. This guard is the runtime equivalent: it catches + * schemas whose output type passed the type check (e.g. through + * `any`) but whose value is malformed. + */ +function isObjectFields(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + return true; } // ============================================================================ // ArgsValidationError // ============================================================================ +/** + * Render a list of `StandardSchemaV1.Issue`s as a human-readable + * string. Reads each issue's `message` field (the standard guarantees + * one) and joins them with newlines. The `.path` is included when + * present so the consumer can locate the failing input. + * + * The function is defensive: a malformed issue (no `message`, + * non-string `message`) does not throw. The audit found that the + * previous `JSON.stringify(issues, null, 2)` would itself throw on a + * circular issue, hiding the `ArgsValidationError` behind a + * `TypeError`. + */ +const renderIssues = (issues: ReadonlyArray): string => { + const parts: string[] = []; + for (const issue of issues) { + if (issue === null || typeof issue !== 'object') { + parts.push(String(issue)); + continue; + } + const message = typeof issue.message === 'string' ? issue.message : ''; + const path = pathToString(issue.path); + parts.push(path ? `${path}: ${message}` : message); + } + return parts.join('\n'); +}; + +/** + * Render a `StandardSchemaV1.Issue.path` as a dotted string. + * + * The spec defines `path` as `ReadonlyArray`, + * where `PropertyKey = string | number | symbol` and `PathSegment` is + * `{ key: PropertyKey }`. Naive `.join('.')` would throw on a `symbol` + * key (`Cannot convert a Symbol value to a string`) and render + * `[object Object]` for an object segment. This helper extracts + * `.key` from object segments and `String()`s every primitive, so + * the result is always a human-readable dotted path. + * + * Examples: + * - `['email']` -> `"email"` + * - `['user', 'name']` -> `"user.name"` + * - `['items', 0, 'id']` -> `"items.0.id"` + * - `[{ key: 'email' }]` -> `"email"` + * - `[Symbol('id')]` -> `"Symbol(id)"` + * - `[null, undefined]` -> `""` (defensive against malformed issues) + */ +const pathToString = (path: unknown): string => { + if (!Array.isArray(path)) return ''; + return path + .map((segment) => { + if (segment === null || segment === undefined) return ''; + if (typeof segment === 'object') { + const key = (segment as { key?: unknown }).key; + return keyToString(key); + } + return keyToString(segment); + }) + .filter((s) => s.length > 0) + .join('.'); +}; + +const keyToString = (key: unknown): string => { + if (key === null || key === undefined) return ''; + if (typeof key === 'symbol') return key.toString(); + if (typeof key === 'string' || typeof key === 'number' || typeof key === 'boolean') { + return String(key); + } + return ''; +}; + /** * Thrown when args supplied to a Standard Schema-backed factory fail * validation. Wraps the validator's issues verbatim so consumers can * introspect or serialize them. * * Catching this error lets the consumer decide whether to surface a - * user-facing message, log to a structured sink, or convert to a different - * format. The validator's raw output is exposed via `.issues` and `.vendor`. + * user-facing message, log to a structured sink, or convert to a + * different format. The validator's raw output is exposed via + * `.issues` and `.vendor`. + * + * The `message` is built from the issues' `.message` fields rather + * than `JSON.stringify` so a circular issue (or any issue whose + * structure is hostile to JSON) does not turn the validation error + * into a `TypeError` from the formatter. * * @example * ```ts @@ -124,8 +291,8 @@ function runSchema( * ValidationError({ field: 1 as unknown as string }); * } catch (e) { * if (e instanceof Error && e.name === 'ArgsValidationError') { - * console.error(e.message); // "Argument validation failed for ValidationError: ..." - * console.error(e.issues); // raw issues + * console.error(e.message); + * console.error(e.issues); * } * } * ``` @@ -136,13 +303,17 @@ export class ArgsValidationError extends Error { /** The vendor of the Standard Schema that produced the failure. */ public readonly vendor: string; /** - * The validator's raw failure result. Typed loosely because each validator - * has its own issue shape; consult your validator's docs for details. + * The validator's raw failure result. Typed as + * `ReadonlyArray` so consumers can read + * `.message` and `.path` without re-casting. */ - public readonly issues: ReadonlyArray; - /** Internal constructor, but exported as a class so consumers can `instanceof`. */ - public constructor(source: string, issues: ReadonlyArray, vendor: string) { - super(`Argument validation failed for "${source}": ${JSON.stringify(issues, null, 2)}`); + public readonly issues: ReadonlyArray; + public constructor( + source: string, + issues: ReadonlyArray, + vendor: string + ) { + super(`Argument validation failed for "${source}": ${renderIssues(issues)}`); this.name = 'ArgsValidationError'; this.source = source; this.issues = issues; @@ -156,32 +327,135 @@ export class ArgsValidationError extends Error { // ============================================================================ /** - * Format the call-site string used in deprecation warnings. Inlined here - * (rather than importing `callsites`) to keep the bundle small. + * Format the call-site string used in deprecation warnings. * * @internal */ function formatCallSite(): string { const err = new Error(); const stack = err.stack ?? ''; - // Walk past the top frames (this function and its callers in error.ts) and - // capture the first userland frame. The format is V8-style - // " at file:line:col". const match = stack.match(/^\s+at\s+(.+?):\d+:\d+\s*$/m); if (match && match[1]) return match[1]; return 'unknown'; } /** - * Creates an error factory function for defining typed, structured errors. + * The normalized view of the public config that the implementation + * body operates on. The `(config: any)` signature at the + * implementation boundary exists so the strict public overloads are + * TS2394-assignable, but the body does not read field-level types + * from it. Destructuring into a `NormalizedConfig` confines the + * `any` to a single structural read: the four fields below are + * typed independently, so the cast does not propagate into the rest + * of the body. + * + * @internal + */ +interface NormalizedConfig { + name: string; + fields: StandardSchemaV1 | undefined; + inherits: AnyErrorFactory | readonly AnyErrorFactory[] | undefined; + message: string | ((data: unknown) => string) | undefined; +} + +const normalize = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + raw: any +): NormalizedConfig => ({ + name: typeof raw?.name === 'string' ? raw.name : '', + fields: raw?.fields, + inherits: raw?.inherits, + message: raw?.message, +}); + +/** + * Fields required to assemble a complete `ErrorInstance`. Lifted + * from the body of `error()` so the helper can construct the + * instance once, with all fields populated before any consumer + * reads it. The previous version assigned `.fields`, `.notes`, + * `.cause`, etc. on a `new Error(...)` cast, leaving a window + * where the type system believed the instance was complete but + * some fields were still undefined. * - * Two configurations are supported: + * @internal + */ +interface InstanceSeed { + name: string; + message: string; + fields: Record; + inherits: AnyErrorFactory | readonly AnyErrorFactory[] | undefined; + stack: string; +} + +/** + * Builds a complete `ErrorInstance` from a seed + the factory + * reference. The factory is attached via a non-writable property + * descriptor on the FACTORY_SYMBOL key, so the marker cannot be + * reassigned by a hostile object. Returns a value typed as the + * full `ErrorInstance>` extension (the + * body is type-erased; the precise shape is conveyed through + * the public overloads' return type). + */ +const buildErrorInstance = ( + factory: AnyErrorFactory, + seed: InstanceSeed +): ErrorInstance> => { + const instance = new Error(seed.message) as ErrorInstance>; + // The native Error carries its own `.message`; we don't reassign it. + Object.defineProperty(instance, 'name', { + value: seed.name, + writable: false, + enumerable: false, + configurable: false, + }); + instance.fields = seed.fields; + instance.notes = []; + instance.cause = null; + instance.context = null; + instance.inherits = seed.inherits; + instance.stack = seed.stack; + instance.from = (cause: Error): ErrorInstance> => { + instance.cause = cause; + return instance; + }; + instance.addNote = (note: string): ErrorInstance> => { + instance.notes.push(note); + return instance; + }; + // Non-writable marker: a consumer or hostile object cannot + // reassign this slot to make the instance pass `is()` checks + // for a different factory. + Object.defineProperty(instance, FACTORY_SYMBOL, { + value: factory, + writable: false, + enumerable: false, + configurable: false, + }); + // R9: register the instance in the package-private registry. + // `is()` consults the registry as the authoritative identity + // (the marker alone is spoofable; the registry is not). + registerInstance(instance); + return instance; +}; + +/** + * Creates an error factory function for defining typed, structured + * errors. Two configurations are supported: * * **Standard path** (RFC 0001): pass `fields: standardSchema` and a - * function-form `message`. Args are validated at instantiation. + * function-form `message`. Args are validated at instantiation. The + * schema's `InferOutput` is constrained to a non-null, non-array + * object at the type level via `ObjectOutputSchema`, and a runtime + * guard rejects malformed outputs. + * + * **Legacy path** (deprecated in 1.4.0, removed in 2.0.0): pass a + * string `message`. No validation runs. * - * **Legacy path** (deprecated in 1.4.0, removed in 2.0.0): pass a string - * `message`. No validation runs. + * The schema overload returns `SchemaErrorFactory`, whose + * call signature is *not* conditional: a factory with a schema + * always requires its input, even for `z.object({})`. The no-schema + * overload returns `ErrorFactory`, the legacy form, which keeps + * the optional-input form for the no-input legacy case. * * @param config - Error configuration * @@ -210,88 +484,166 @@ function formatCallSite(): string { * }); * ``` */ -export function error = Record>(config: { +// R7 + R8 + R9 + R10: the schema overload constrains the schema's +// output to a non-null, non-array object via a structural +// intersection on the `fields` parameter. The R10 audit found +// that a pure-type reject at the call site **is** possible, by +// intersecting `S` with a phantom that resolves to `never` when +// the predicate is false: +// +// fields: S & (IsObjectOutput> extends true +// ? unknown +// : never); +// +// When the user supplies a schema with `InferOutput = null`, the +// intersection becomes `S & never = never`, and the call errors: +// the user's value does not satisfy the parameter type. The +// earlier diagnosis ("TypeScript's function-arity flexibility +// prevents a type-level reject") was incorrect — that diagnosis +// applied to a constraint on the `message` arity, not on +// `fields`. The audit's repro confirms the gate fires for +// `null`, `unknown[]`, and primitive outputs while preserving +// the input/output inference for record outputs. +// +// The `inherits` parameter is constrained via `ParentFor>` +// (a contravariance witness — requires `strictFunctionTypes`). + +export function error(config: { + name: string; + fields: S & (IsObjectOutput> extends true ? unknown : never); + message: (data: StandardSchemaV1.InferOutput) => string; + inherits?: + | ParentFor>> + | readonly ParentFor>>[]; +}): SchemaErrorFactory, StandardSchemaV1.InferOutput>; + +export function error = Record>(config: { name: string; - fields?: StandardSchemaV1; + fields?: undefined; message?: string | ((data: T) => string); - inherits?: ErrorFactory | ErrorFactory[]; -}): ErrorFactory { - const { name, fields, inherits, message } = config; + inherits?: ParentFor> | readonly ParentFor>[]; +}): ErrorFactory; - // Decide API mode up front and surface call sites early so the deprecation - // warning points at the user's call. - const isStandard = fields !== undefined && typeof message === 'function'; +// The implementation signature is `(config: any)` because the public +// overloads (above) must be assignable to it (TS2394-safe). The +// `any` is an explicit internal boundary: the constraint runs at +// the public overloads' parameter types, not at the implementation. +// The body destructures through `normalize()` so the `any` is read +// once and confined; the rest of the body operates on a typed +// `NormalizedConfig`. +export function error( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: any +): AnyErrorFactory { + const { name, fields, inherits, message } = normalize(config); + + // R9: build the inherits snapshot once, before any closure + // captures it. The factory metadata and every produced instance + // share the same frozen reference. The previous version froze + // a copy on the factory but let the closure pass the caller's + // original list to the instance — a caller-side mutation on + // the shared list would still be visible on `instance.inherits`. + // Sharing the snapshot closes that gap. + // + // The cast strips `Readonly<...>` from `Object.freeze`'s + // widened return type so the snapshot stays assignable to the + // non-readonly parameter type the rest of the body uses. The + // runtime freezing is what protects against caller mutations; + // the type is a convenience, not a contract. + const inheritsSnapshot: AnyErrorFactory | readonly AnyErrorFactory[] | undefined = + inherits === undefined + ? undefined + : Array.isArray(inherits) + ? (Object.freeze([...inherits]) as readonly AnyErrorFactory[]) + : (Object.freeze(inherits) as AnyErrorFactory); + + const hasSchema = fields !== undefined; + const hasFunctionMessage = typeof message === 'function'; + + const ErrorFactoryInstance: ErrorFactory, Record> = ( + input?: Partial> + ): ErrorInstance> => { + // Runtime safety net: when a factory carries a schema, the + // input is required at the call site. The schema path now also + // has a non-optional call signature (see `SchemaErrorFactory`), + // so this is the safety net for JS callers and any + // `error({fields: schema})()` call that bypassed the type + // system. + if (input === undefined && hasSchema) { + throw new TypeError( + `error("${name}") was called with no arguments. The factory ` + + `carries a schema, so the input shape is required. Pass ` + + `the validated input, e.g. ${name}({ ... }).` + ); + } - /** - * Error factory function - creates error instances. - */ - const ErrorFactoryInstance: ErrorFactory = (input?: Partial): ErrorInstance => { let fieldsData: Record = {}; let errorMessage = name; - if (isStandard) { - if (fields === undefined || typeof message !== 'function') { - // Unreachable at runtime; the overloads guarantee both are present. - throw new Error('Internal: standard mode without fields or message function'); + if (hasSchema) { + if (fields === undefined) { + throw new Error('Internal: schema branch entered without fields'); } - const result = runSchema(fields, input); + const result = runSchema(fields, input, name); if (!result.ok) { + throw new ArgsValidationError(name, result.issues, fields['~standard'].vendor); + } + if (!isObjectFields(result.value)) { + // Runtime mirror of the type-level `IsObjectOutput` gate. + // A schema whose validated output is null, a primitive, or + // an array violates the `instance.fields: Record` contract. The previous version silently coerced + // to `{}` via `?? {}`, hiding the bug from the consumer. throw new ArgsValidationError( name, - result.issues as ReadonlyArray, + [ + { + message: + 'Schema output must be a non-null object. The transformation returned ' + + (result.value === null + ? 'null' + : Array.isArray(result.value) + ? 'an array' + : `a ${typeof result.value} value`) + + '.', + }, + ], fields['~standard'].vendor ); } - fieldsData = (result.value as Record) ?? {}; - errorMessage = (message as (data: T) => string)(fieldsData as unknown as T); + fieldsData = result.value; + if (hasFunctionMessage && typeof message === 'function') { + errorMessage = (message as (data: Record) => string)(fieldsData); + } } else { - // Legacy path — coerce input and interpolate the template if any. - fieldsData = (input && typeof input === 'object' ? input : {}) as Record; + // Legacy path — no schema. Accepts a string template, a plain + // string, or a function-form message. + if (input !== undefined && typeof input === 'object' && input !== null) { + fieldsData = input as Record; + } if (typeof message === 'string' && hasTemplatePlaceholders(message)) { + // The legacy deprecation is for the *template* form: a + // string `message` with `{field}` placeholders. A plain + // string (no placeholders) and a function-form message do + // not warn. + warnLegacy(formatCallSite()); errorMessage = formatTemplate(message, fieldsData); } else if (typeof message === 'string') { errorMessage = message; + } else if (hasFunctionMessage && typeof message === 'function') { + errorMessage = (message as (data: Record) => string)(fieldsData); } - // The deprecation marker is gated by the warning once per call site. - // Set `process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES = "1"` to silence. - warnLegacy(formatCallSite()); } - // Capture stack trace - const stack = captureStack(errorMessage); - - // Create error instance using native Error - const instance = new Error(errorMessage) as ErrorInstance; - instance.name = name; - instance.fields = fieldsData as unknown as T; - instance.notes = []; - instance.cause = null; - instance.causes = []; - instance.context = null; - instance.inherits = inherits ?? undefined; - instance.stack = stack; - - // Add .from() method for exception chaining - instance.from = (cause: Error): ErrorInstance => { - // Build new causes array: [new cause] + [cause's causes] + [existing causes of instance] - // This maintains chronological order: newest first - const causeCauses = 'causes' in cause && Array.isArray(cause.causes) ? cause.causes : []; - instance.causes = [cause, ...causeCauses, ...instance.causes]; - instance.cause = cause; - return instance; - }; - - // Add .addNote() method for runtime context (PEP 678) - instance.addNote = (note: string): ErrorInstance => { - instance.notes.push(note); - return instance; - }; - - // Mark this instance as created by this factory (for is() checks) - (instance as unknown as Record unknown>)[FACTORY_SYMBOL] = - ErrorFactoryInstance; + const stack = captureStack(errorMessage, ErrorFactoryInstance); - return instance; + return buildErrorInstance(ErrorFactoryInstance, { + name, + message: errorMessage, + fields: fieldsData, + inherits: inheritsSnapshot, + stack, + }); }; // Attach metadata to the factory function @@ -302,18 +654,47 @@ export function error = Record).inherits = inherits; + // R7 compatibility witness: a hidden function-typed property used + // by the type checker to enforce inheritance compatibility + // contravariantly. The function is never called at runtime. + Object.defineProperty(ErrorFactoryInstance, acceptsFields, { + value: (_fields: unknown) => undefined, + writable: false, + enumerable: false, + configurable: false, + }); + + // R9: the snapshot built above is the single source of truth + // for the parents list. The factory's metadata and every + // produced instance share the same frozen reference. R8 froze + // a separate copy here and let the closure pass the caller's + // original list to instances; that left `instance.inherits` + // exposed to caller-side mutations on the shared list. Sharing + // the snapshot closes the gap. + if (inheritsSnapshot !== undefined) { + ( + ErrorFactoryInstance as ErrorFactory, Record> + ).inherits = inheritsSnapshot; } if (fields !== undefined) { - (ErrorFactoryInstance as ErrorFactory).schema = fields; + ( + ErrorFactoryInstance as ErrorFactory, Record> + ).schema = fields; } if (message !== undefined) { - (ErrorFactoryInstance as ErrorFactory).rawMessage = message; + ( + ErrorFactoryInstance as ErrorFactory, Record> + ).rawMessage = message; } + // Freeze the factory's metadata. The factory's `name`, `inherits`, + // and `schema` are part of the type contract and must not change + // after construction. The `rawMessage` and the function name are + // already non-writable via defineProperty above. + Object.freeze(ErrorFactoryInstance); + return ErrorFactoryInstance; } @@ -321,4 +702,4 @@ export function error = Record