diff --git a/.changeset/add-addnote-method.md b/.changeset/add-addnote-method.md deleted file mode 100644 index 3bfbab7..0000000 --- a/.changeset/add-addnote-method.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": minor ---- - -Add `ErrorInstance.addNote(note)` for attaching runtime context to errors (PEP 678, mirrors Python 3.11). Returns the instance for chaining. The `notes: string[]` property was already implemented; the method was missing despite being documented. Closes #29. diff --git a/.changeset/add-release-environment.md b/.changeset/add-release-environment.md deleted file mode 100644 index 96fd689..0000000 --- a/.changeset/add-release-environment.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Tag the release job with `environment: release` so the run is recorded as a deployment to the `release` GitHub environment. Future hardening (required reviewers, branch restrictions, wait timer) can attach to the same environment without further workflow changes. Provenance and trusted publishing are unaffected. diff --git a/.changeset/add-repository-field.md b/.changeset/add-repository-field.md deleted file mode 100644 index 7614eab..0000000 --- a/.changeset/add-repository-field.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Add the missing `repository` field to `packages/errors/package.json`. Trusted publishing with provenance requires `package.json:repository.url` to match the GitHub repo URL from the OIDC token — without it, npm rejects the publish with `E422: Error verifying sigstore provenance bundle: "repository.url" is ""`. diff --git a/.changeset/add-staging-changeset-lint.md b/.changeset/add-staging-changeset-lint.md deleted file mode 100644 index 74511e3..0000000 --- a/.changeset/add-staging-changeset-lint.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": minor ---- - -Add CI lint that requires a changeset on every PR to `staging`. Part of the release system plan (Phase 4). diff --git a/.changeset/docs-add-process-and-stacked-pr-learnings.md b/.changeset/docs-add-process-and-stacked-pr-learnings.md deleted file mode 100644 index e1288fa..0000000 --- a/.changeset/docs-add-process-and-stacked-pr-learnings.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Add documentation under `docs/internal/engineering/process/` (`implementing-an-issue.md`, `releasing-a-new-version.md`, `pr-authoring.md`) and `docs/learnings/github/stacked-pr/README.md`. No code or workflow changes. The changeset is required by the current `ci.yml` lint; it does not represent a feature bump. diff --git a/.changeset/docs-plan-trusted-publishing.md b/.changeset/docs-plan-trusted-publishing.md deleted file mode 100644 index dbf33c0..0000000 --- a/.changeset/docs-plan-trusted-publishing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Update `docs/internal/engineering/plans/release-system.md` to reflect the post-plan additions: `Section 7 — Trusted publishing & environment` documents npm OIDC trusted publishing and the `release` GitHub environment. `Appendix C — Post-plan decision log` captures the new decisions. `Definition of done` adds two new items for OIDC publish and env record. `Status` moves from "Proposed" to "Approved and partially implemented on `staging`". diff --git a/.changeset/docs-resolve-rule-inconsistencies.md b/.changeset/docs-resolve-rule-inconsistencies.md deleted file mode 100644 index 2ed8985..0000000 --- a/.changeset/docs-resolve-rule-inconsistencies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Resolve twelve internal inconsistencies across the architecture rules in `docs/engineering/architecture/rules/` (rules 0001-0016, INDEX.md, README.md). Each fix is a clarification that aligns the text with the doctrine already expressed elsewhere in the ruleset; no new doctrine is introduced and no existing constraint is weakened. Includes: INDEX title and 0015 summary refresh, README length policy softening, threshold disambiguation across 0001/0002/0003/0005, 0016 self-contradiction on `run`/`execute`, 0013/0012/0014 reconciliation, and the 0010/0015 carve-out for environment values. The published runtime is unchanged. diff --git a/.changeset/docs-update-branching-model.md b/.changeset/docs-update-branching-model.md deleted file mode 100644 index fc6a55c..0000000 --- a/.changeset/docs-update-branching-model.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Update `CLAUDE.md` and `CONTRIBUTING.md` to reflect the actual branching model: devs land on `staging`, release engineer cherry-picks to `main` with a `version bump` label, hotfixes branch from `main`. The previous `main <- staging <- dev` model was documented but not practiced. Part of the release system plan (Phase 5). diff --git a/.changeset/feat-83-standard-schema-inference.md b/.changeset/feat-83-standard-schema-inference.md deleted file mode 100644 index 9870f8f..0000000 --- a/.changeset/feat-83-standard-schema-inference.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@deessejs/errors": minor ---- - -Implement Standard Schema type inference in `error()`. The `>` placeholder parameter is gone. The field shape is now derived from the `fields: StandardSchemaV1` parameter via a new `InferFields` helper that uses `StandardSchemaV1.InferOutput & Record` to satisfy the `ErrorFactory` constraint while preserving the precise inferred type at the call site. - -Consumers passing a typed Standard Schema-compliant validator (Zod, Valibot, ArkType, etc.) now receive a factory whose return type carries the schema's output shape — no more `error(...)` boilerplate. The trailing cast `return ErrorFactoryInstance as ErrorFactory` is preserved (single cast, single boundary, rule 0008 compliant) but the gap it bridges is now narrower: the cast only widens at the metadata-attachment boundary, not at the type-parameter boundary. - -Closes #83. The package's runtime behavior is unchanged; 85 tests pass (82 → 85, three new inference regression tests). The `ErrorConfig` type in `types.ts` is updated to mirror the new public signature. \ No newline at end of file diff --git a/.changeset/feat-88-class-internals.md b/.changeset/feat-88-class-internals.md deleted file mode 100644 index d37846d..0000000 --- a/.changeset/feat-88-class-internals.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": minor ---- - -Resync staging with main after #91 was merged ahead of the 1.4.0 release. The class-based internals refactor (`ErrorInstanceImpl` private class) was superseded by 1.4.0's function-based implementation with RFC 0001 Standard Schema validation, `ArgsValidationError`, and `message-as-function` mode. The structural improvement (instance instanceof Error, real class methods) is preserved in spirit through RFC 0001's type-level guarantees. Closes #88. diff --git a/.changeset/fix-71-chained-cast-error-instance-marker.md b/.changeset/fix-71-chained-cast-error-instance-marker.md deleted file mode 100644 index fe71e36..0000000 --- a/.changeset/fix-71-chained-cast-error-instance-marker.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Replace the chained type assertion `(instance as unknown as Record unknown>)[FACTORY_SYMBOL]` in `error.ts` with a single property assignment on the declared `ErrorInstance` type. The `FACTORY_SYMBOL` is now declared on `ErrorInstance` in `types.ts`; the marker assignment is type-checked, and the `as unknown` cast is gone. Closes #71. The package's public API is unchanged; all 83 tests pass. diff --git a/.changeset/fix-74-causes-structural-guard.md b/.changeset/fix-74-causes-structural-guard.md deleted file mode 100644 index 92e634b..0000000 --- a/.changeset/fix-74-causes-structural-guard.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Replace the cast in `causes/index.ts` (`error as ErrorInstance`) with a structural guard (`'causes' in error && Array.isArray(error.causes)`). The function no longer imports `ErrorInstance` and the input is honest about its `unknown` shape. The first example in the JSDoc was broken (a template literal cut mid-sentence); it is now a complete try/catch example. A regression test verifies that an object carrying `causes: 'not an array'`, `causes: null`, or an array-like (non-`Array`) value returns `[]`. Public API unchanged; 83 tests pass (82 → 83). Closes #74. \ No newline at end of file diff --git a/.changeset/fix-oidc-publish.md b/.changeset/fix-oidc-publish.md deleted file mode 100644 index be46f27..0000000 --- a/.changeset/fix-oidc-publish.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Fix npm trusted publishing (OIDC) end-to-end. Three changes: - -1. Release workflow bumps Node 22 → 24 (the runner default; matches our other release workflows, and aligns with npm CLI ≥ 11.5.1 + Node ≥ 22.14.0 requirements for trusted publishing). -2. `Publish packages` step now passes `env: NPM_CONFIG_PROVENANCE: 'true'`, which forces pnpm publish down the OIDC code path instead of falling back to a token. -3. `packages/errors/package.json` now declares `publishConfig.provenance: true`, so npm always emits a provenance attestation on publish (belt + suspenders alongside the env var). diff --git a/.changeset/fix-release-version-commit.md b/.changeset/fix-release-version-commit.md deleted file mode 100644 index 2f6cbe1..0000000 --- a/.changeset/fix-release-version-commit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Fix a bug in the release workflow: `git diff --quiet` (without `--cached`) compared the working tree to the index, which is in sync immediately after `git add -A`. This caused the version bump commit to be skipped, leaving the working tree in a `pnpm changeset publish`-able state but never pushed to `main`. Use `git diff --cached --quiet` so the comparison is against the last commit (HEAD), which is what we actually want. diff --git a/.changeset/release-workflow-rewrite.md b/.changeset/release-workflow-rewrite.md deleted file mode 100644 index afd016f..0000000 --- a/.changeset/release-workflow-rewrite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Rewrite the release workflow to detect pending changesets explicitly and gate all publish steps on detection. Tag is now pushed at the version bump commit (not the merge commit), fixing the `@deessejs/errors@1.1.1` tag drift. Adds `dry_run` and `packages` inputs to `workflow_dispatch`. Part of the release system plan (Phase 3). diff --git a/.changeset/remove-version-bump-label.md b/.changeset/remove-version-bump-label.md deleted file mode 100644 index 9638166..0000000 --- a/.changeset/remove-version-bump-label.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Remove the `version bump` label gate from the release workflow. Every PR merged to `main` now produces a release if it contains `.changeset/*.md` files in its diff. The `has_changesets` detection step is the only condition. Simplifies the release engineer's job — no more remembering to label. diff --git a/.changeset/resync-main-to-staging.md b/.changeset/resync-main-to-staging.md new file mode 100644 index 0000000..aaeebc4 --- /dev/null +++ b/.changeset/resync-main-to-staging.md @@ -0,0 +1,22 @@ +--- +'@deessejs/errors': major +--- + +chore: resync `main` into `staging` (resolves the conflicts in #108) + +This changeset exists to satisfy the CI lint (`pnpm changeset status --since=origin/staging`). The resync merge from `main` brings the following into `staging`: + +- 18 changesets from main (already published in `@deessejs/errors@2.0.0` on the npm registry) +- `packages/errors/package.json` `scripts` section (main added `type-check:test`, `test:consumer`, `test:docs`; staging has migrated `lint` to `oxlint`; this resync combines both sets) +- 5 `apps/web/content/docs/*.mdx` files (api-reference, causes-function, error-factory, fields-schema, message-templates) — main version, which is the post-audit-refactor canonical version from PR #99 +- `.github/workflows/ci.yml` (consolidation from PR #92) +- `.gitignore` (additional ignores) +- `.claude/agents/` and `.claude/agent-memory/` removals (from PR #98) +- `vercel.json` fix (from PR #100 — sets `outputDirectory: ".next"` since Vercel's Root Directory is `apps/web`) +- New tests in `packages/errors/tests/` from the WIP audit response (PR #99) +- `packages/errors/CHANGELOG.md` (new release entries) +- Documentation files in `apps/web/content/docs/` + +The `version` field in `packages/errors/package.json` is bumped from `1.4.0` to `2.0.0` because main had already published 2.0.0 with the audit response changes; staging was behind. The `major` bump on this changeset reflects that fact for `pnpm changeset status --since=origin/staging` to validate the version change. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/.changeset/seo-test-harness.md b/.changeset/seo-test-harness.md deleted file mode 100644 index 20afa86..0000000 --- a/.changeset/seo-test-harness.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Add a Vitest harness in `apps/web/` that locks in the SEO surface the v1.4.1 cleanup batch protects (`apps/web/tests/seo/`). The suite runs in CI on every PR to `staging`; it does not change any published runtime. The `apps/web` workspace does not publish, so the changeset is included solely to satisfy the `Require changeset` lint in `.github/workflows/ci.yml` for PRs that touch files outside `apps/web/` itself. diff --git a/.changeset/switch-to-trusted-publishing.md b/.changeset/switch-to-trusted-publishing.md deleted file mode 100644 index ab285dd..0000000 --- a/.changeset/switch-to-trusted-publishing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@deessejs/errors": patch ---- - -Switch the release workflow to npm trusted publishing (OIDC) instead of `secrets.NPM_TOKEN`. The `id-token: write` permission, already declared on the job, is sufficient for GitHub to mint the OIDC token that npm exchanges for a short-lived publish credential. Provenance is generated automatically on public repos. The `NPM_TOKEN` secret can be revoked once the first OIDC publish succeeds. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c47d95..0eb37cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,290 @@ name: ci on: + push: + branches: [main, staging, dev] pull_request: - branches: [staging] + branches: [main, staging, dev] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + # ============================================================================ + # Build + # ============================================================================ + build: + name: Build + 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 + + - name: Run build + run: pnpm build + + # ============================================================================ + # Lint + # ============================================================================ + lint: + name: Lint + 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 + + - name: Run prettier check + run: pnpm format:check + + - name: Run lint + run: pnpm lint + + # ============================================================================ + # Tests (matrix: Node 20, 22, 24) + # ============================================================================ + test: + name: Tests (node ${{ matrix.node }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] + + 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: ${{ matrix.node }} + 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 + + - name: Run tests + run: pnpm test + + # Apps/web SEO regression suite (apps/web/tests/seo). Runs after the + # root test pipeline so we benefit from the existing install cache. + # The suite is a pure-Vitest run; no real Next build is required. + - name: Run apps/web SEO tests + run: pnpm --filter web test:run + + # Apps/web has a test-specific tsconfig that enables + # `allowImportingTsExtensions` so tests can target routes whose + # 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. 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 + + # ============================================================================ + # Type Check (matrix: Node 20, 22, 24) + # ============================================================================ + type-check: + name: Type Check (node ${{ matrix.node }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] + + 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: ${{ matrix.node }} + 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 + + - 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) + # ============================================================================ changeset-check: name: Changeset required + if: github.event_name == 'pull_request' && github.base_ref == 'staging' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 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/.oxlintrc.json b/.oxlintrc.json index 0ae416b..f4fdc54 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -30,4 +30,3 @@ }, "ignorePatterns": ["node_modules/", "dist/", ".next/", ".turbo/", "coverage/", "*.log"] } - diff --git a/apps/web/content/docs/api-reference.mdx b/apps/web/content/docs/api-reference.mdx index dbebf43..6ac3f53 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 | +| Parameter | Type | Description | +| --------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| config.name | string | Error name identifier (required) | +| 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 | +| Property | Type | Description | +| ---------- | ------------------------------------------- | ------------------------------------------------------------------------------ | +| name | string | The error name | +| inherits | ErrorFactory or ErrorFactory[] or undefined | Parent error types | +| 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, }); @@ -74,18 +86,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 @@ -113,7 +135,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'); @@ -121,13 +144,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[] @@ -141,7 +173,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 @@ -149,11 +181,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] ``` --- @@ -218,24 +255,23 @@ The type of object returned by error factories. It extends the native Error type ### Properties -| Property | Type | Description | -| -------- | ------------------------------------------- | ---------------------------- | -| name | string | Error name identifier | -| message | string | Human-readable error message | -| stack | string | Stack trace string | -| fields | T | Validated fields | -| notes | string[] | Additional notes (PEP 678) | -| cause | Error or null | Direct cause of this error | -| causes | Error[] | Full cause chain | -| context | Record<string, unknown> or null | Injected context data | -| inherits | ErrorFactory or ErrorFactory[] or undefined | Parent error factories | +| Property | Type | Description | +| -------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | string | Error name identifier | +| message | string | Human-readable error message | +| stack | string | Stack trace string | +| 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. 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 (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. | +| Method | Description | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------- | +| 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. | --- @@ -255,3 +291,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 1ea20f3..3d72c66 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,17 +111,41 @@ 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)); +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 diff --git a/apps/web/content/docs/error-factory.mdx b/apps/web/content/docs/error-factory.mdx index 4500335..6347a32 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 009ac6e..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 diff --git a/apps/web/content/docs/fields-schema.mdx b/apps/web/content/docs/fields-schema.mdx index 17aacc1..8cbab8d 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 c179639..8ed6c20 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 a7abd4c..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,7 +135,7 @@ 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. diff --git a/apps/web/content/docs/message-templates.mdx b/apps/web/content/docs/message-templates.mdx index 10ca48f..5eb545e 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 @@ -63,6 +63,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 @@ -70,42 +73,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 1262bbf..d8c08b6 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,49 @@ 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 +149,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 diff --git a/apps/web/content/docs/recipes.mdx b/apps/web/content/docs/recipes.mdx index 6a5c184..561fb78 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 @@ -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) { @@ -82,32 +91,40 @@ 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 +133,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:', @@ -162,32 +195,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; - } } ``` @@ -195,10 +233,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' }); @@ -207,26 +246,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`. } } ``` diff --git a/apps/web/content/docs/single-inheritance.mdx b/apps/web/content/docs/single-inheritance.mdx index 37eaeaf..c59697d 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 { z } from 'zod'; import { error, is } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); -const ValidationError = error<{ field: string }>({ +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 +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" +import { error } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ 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 diff --git a/apps/web/content/docs/type-checking.mdx b/apps/web/content/docs/type-checking.mdx index eb0eaef..0520048 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/CHANGELOG.md b/packages/errors/CHANGELOG.md index bf48c4c..971668e 100644 --- a/packages/errors/CHANGELOG.md +++ b/packages/errors/CHANGELOG.md @@ -1,5 +1,259 @@ # @deessejs/errors +## 2.0.0 + +### Major Changes + +- 1dadb81: # 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. + +### Minor Changes + +- 172e81c: Add `ErrorInstance.addNote(note)` for attaching runtime context to errors (PEP 678, mirrors Python 3.11). Returns the instance for chaining. The `notes: string[]` property was already implemented; the method was missing despite being documented. Closes #29. +- 81a1347: Add CI lint that requires a changeset on every PR to `staging`. Part of the release system plan (Phase 4). +- 91ceaf0: Implement Standard Schema type inference in `error()`. The `>` placeholder parameter is gone. The field shape is now derived from the `fields: StandardSchemaV1` parameter via a new `InferFields` helper that uses `StandardSchemaV1.InferOutput & Record` to satisfy the `ErrorFactory` constraint while preserving the precise inferred type at the call site. + + Consumers passing a typed Standard Schema-compliant validator (Zod, Valibot, ArkType, etc.) now receive a factory whose return type carries the schema's output shape — no more `error(...)` boilerplate. The trailing cast `return ErrorFactoryInstance as ErrorFactory` is preserved (single cast, single boundary, rule 0008 compliant) but the gap it bridges is now narrower: the cast only widens at the metadata-attachment boundary, not at the type-parameter boundary. + + Closes #83. The package's runtime behavior is unchanged; 85 tests pass (82 → 85, three new inference regression tests). The `ErrorConfig` type in `types.ts` is updated to mirror the new public signature. + +- ce629c4: Resync staging with main after #91 was merged ahead of the 1.4.0 release. The class-based internals refactor (`ErrorInstanceImpl` private class) was superseded by 1.4.0's function-based implementation with RFC 0001 Standard Schema validation, `ArgsValidationError`, and `message-as-function` mode. The structural improvement (instance instanceof Error, real class methods) is preserved in spirit through RFC 0001's type-level guarantees. Closes #88. + +### Patch Changes + +- 5a6f11c: Tag the release job with `environment: release` so the run is recorded as a deployment to the `release` GitHub environment. Future hardening (required reviewers, branch restrictions, wait timer) can attach to the same environment without further workflow changes. Provenance and trusted publishing are unaffected. +- 3c0c1d0: Add the missing `repository` field to `packages/errors/package.json`. Trusted publishing with provenance requires `package.json:repository.url` to match the GitHub repo URL from the OIDC token — without it, npm rejects the publish with `E422: Error verifying sigstore provenance bundle: "repository.url" is ""`. +- 46ed266: Add documentation under `docs/internal/engineering/process/` (`implementing-an-issue.md`, `releasing-a-new-version.md`, `pr-authoring.md`) and `docs/learnings/github/stacked-pr/README.md`. No code or workflow changes. The changeset is required by the current `ci.yml` lint; it does not represent a feature bump. +- e5de45f: Update `docs/internal/engineering/plans/release-system.md` to reflect the post-plan additions: `Section 7 — Trusted publishing & environment` documents npm OIDC trusted publishing and the `release` GitHub environment. `Appendix C — Post-plan decision log` captures the new decisions. `Definition of done` adds two new items for OIDC publish and env record. `Status` moves from "Proposed" to "Approved and partially implemented on `staging`". +- 5016fa3: Resolve twelve internal inconsistencies across the architecture rules in `docs/engineering/architecture/rules/` (rules 0001-0016, INDEX.md, README.md). Each fix is a clarification that aligns the text with the doctrine already expressed elsewhere in the ruleset; no new doctrine is introduced and no existing constraint is weakened. Includes: INDEX title and 0015 summary refresh, README length policy softening, threshold disambiguation across 0001/0002/0003/0005, 0016 self-contradiction on `run`/`execute`, 0013/0012/0014 reconciliation, and the 0010/0015 carve-out for environment values. The published runtime is unchanged. +- f710cdc: Update `CLAUDE.md` and `CONTRIBUTING.md` to reflect the actual branching model: devs land on `staging`, release engineer cherry-picks to `main` with a `version bump` label, hotfixes branch from `main`. The previous `main <- staging <- dev` model was documented but not practiced. Part of the release system plan (Phase 5). +- 204b267: Replace the chained type assertion `(instance as unknown as Record unknown>)[FACTORY_SYMBOL]` in `error.ts` with a single property assignment on the declared `ErrorInstance` type. The `FACTORY_SYMBOL` is now declared on `ErrorInstance` in `types.ts`; the marker assignment is type-checked, and the `as unknown` cast is gone. Closes #71. The package's public API is unchanged; all 83 tests pass. +- 8a268a7: Replace the cast in `causes/index.ts` (`error as ErrorInstance`) with a structural guard (`'causes' in error && Array.isArray(error.causes)`). The function no longer imports `ErrorInstance` and the input is honest about its `unknown` shape. The first example in the JSDoc was broken (a template literal cut mid-sentence); it is now a complete try/catch example. A regression test verifies that an object carrying `causes: 'not an array'`, `causes: null`, or an array-like (non-`Array`) value returns `[]`. Public API unchanged; 83 tests pass (82 → 83). Closes #74. +- 30dc048: Fix npm trusted publishing (OIDC) end-to-end. Three changes: + + 1. Release workflow bumps Node 22 → 24 (the runner default; matches our other release workflows, and aligns with npm CLI ≥ 11.5.1 + Node ≥ 22.14.0 requirements for trusted publishing). + 2. `Publish packages` step now passes `env: NPM_CONFIG_PROVENANCE: 'true'`, which forces pnpm publish down the OIDC code path instead of falling back to a token. + 3. `packages/errors/package.json` now declares `publishConfig.provenance: true`, so npm always emits a provenance attestation on publish (belt + suspenders alongside the env var). + +- 8648722: Fix a bug in the release workflow: `git diff --quiet` (without `--cached`) compared the working tree to the index, which is in sync immediately after `git add -A`. This caused the version bump commit to be skipped, leaving the working tree in a `pnpm changeset publish`-able state but never pushed to `main`. Use `git diff --cached --quiet` so the comparison is against the last commit (HEAD), which is what we actually want. +- 260f046: Rewrite the release workflow to detect pending changesets explicitly and gate all publish steps on detection. Tag is now pushed at the version bump commit (not the merge commit), fixing the `@deessejs/errors@1.1.1` tag drift. Adds `dry_run` and `packages` inputs to `workflow_dispatch`. Part of the release system plan (Phase 3). +- 4380419: Remove the `version bump` label gate from the release workflow. Every PR merged to `main` now produces a release if it contains `.changeset/*.md` files in its diff. The `has_changesets` detection step is the only condition. Simplifies the release engineer's job — no more remembering to label. +- 191ca85: Add a Vitest harness in `apps/web/` that locks in the SEO surface the v1.4.1 cleanup batch protects (`apps/web/tests/seo/`). The suite runs in CI on every PR to `staging`; it does not change any published runtime. The `apps/web` workspace does not publish, so the changeset is included solely to satisfy the `Require changeset` lint in `.github/workflows/ci.yml` for PRs that touch files outside `apps/web/` itself. +- e453eb5: Switch the release workflow to npm trusted publishing (OIDC) instead of `secrets.NPM_TOKEN`. The `id-token: write` permission, already declared on the job, is sufficient for GitHub to mint the OIDC token that npm exchanges for a short-lived publish credential. Provenance is generated automatically on public repos. The `NPM_TOKEN` secret can be revoked once the first OIDC publish succeeds. + ## 1.4.0 ### Minor Changes 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 3792998..f7880ea 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -1,6 +1,6 @@ { "name": "@deessejs/errors", - "version": "1.4.0", + "version": "2.0.0", "description": "Lightweight, type-safe error handling for TypeScript — Python-style exception chaining, hierarchical inheritance, and rich error semantics. Function-based API, ESM-only, designed for first-class interoperability with @deessejs/fp.", "homepage": "https://errors.deessejs.com", "repository": { @@ -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": "oxlint", "lint:fix": "oxlint --fix" }, 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