From 40390c9e23a735a8b14847eea2ea7ff855698554 Mon Sep 17 00:00:00 2001 From: Marty Date: Wed, 7 Oct 2026 16:42:05 +0200 Subject: [PATCH 1/7] ci(workflows): consolidate to single ci.yml (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four separate workflow files (build.yml, lint.yml, tests.yml, types.yml) plus a partial ci.yml had drifted into a confusing split: each PR showed only 4-5 of the 9 actual checks at a glance, and the changeset check was hidden behind a separate file that did not run on push. Consolidate everything into a single ci.yml with 5 jobs (build, lint, test with 3-Node matrix, type-check with 3-Node matrix, changeset required on PRs to staging). The view in the GitHub Actions UI now matches what 'gh pr checks' reports: all 9 checks visible in one place, named consistently. Release workflow (release.yml) stays separate — different trigger (workflow_dispatch / PR merge to main), different permissions, and a different lifetime (one-shot per release rather than per-commit). Co-authored-by: T3 Code --- .github/workflows/ci.yml | 168 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c47d95..5605560 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,178 @@ name: ci on: + push: + branches: [main, staging, dev] pull_request: - branches: [staging] + branches: [main, staging, dev] + +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. + - 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 + + # ============================================================================ + # 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 From 1dadb816731c22288820265ce9e5f25517178f34 Mon Sep 17 00:00:00 2001 From: Marty Date: Fri, 9 Oct 2026 15:36:27 +0200 Subject: [PATCH 2/7] WIP: tighten type, validation, and causality contracts (audit response) (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: types(schema): add InferStandardSchemaInput, factory I/O generics Partial progress on the audit recommended in the project (self-audit, see PR description for full context). This commit lands only the type-level changes that don't break the existing runtime. Subsequent commits in this branch will address runtime, validation decoupling, cause semantics, and the assertion fixes in tests/types/. Changes: - types.ts: InferStandardSchemaInput in addition to InferStandardSchemaOutput. Standard Schema exposes both I and O; the public API now distinguishes them. - types.ts: ErrorFactory replaces the single ErrorFactory type parameter. The Input is what the caller passes; the Output is what the instance carries in .fields. They may differ for schemas that transform. - types.ts: ErrorInstance.causes is re-annotated as @deprecated. The flat list conflates historical .from() calls with a true causal chain. Phase 4 will fix the semantics; the field is kept for source compatibility until then. - raise/index.ts: raise is now generic over the ErrorInstance's field type, so raise(E({ id: 'x' })) compiles regardless of T. Previously the default Record rejected all non-empty ErrorInstance types. Not changed yet (intentional, deferred to subsequent commits): - error() signature: still T extends Record. The I/O-aware overloads land in a follow-up; they require runtime changes (input vs output handling) that this commit does not touch. - is() type guards: native vs factory discrimination deferred. - Validation: still gated on message being a function. - causes(): semantics unchanged. - Tests in tests/types/error-type.test.ts: still express invariants the runtime doesn't yet hold. Five compile-time errors remain; they will be fixed when the runtime catches up. Validation (against current state of main): - pnpm type-check (src): clean - pnpm test:run: 146/146 pass - pnpm build: clean - pnpm lint: 0 errors * fix(types): enable typecheck on tests, fix erroneous assertions Addresses audit Phase 1 (TypeScript type tests not running in CI) and parts of Phase 2/4/6 (variance, I/O inference, is() discrimination). - types.ts: ErrorFactory separates input from output; AddStandardSchemaInput added; AnyErrorFactory alias for variance-tolerant lists; ErrorInstance.from(cause) accepts any ErrorInstance for type compatibility. - error.ts: inherits accepts AnyErrorFactory | AnyErrorFactory[]. - is/index.ts: split into two overloads — one for AnyErrorFactory (returns ErrorInstance), one for ErrorConstructor (returns Error). Eliminates the false promise that native errors carry .fields etc. - tsconfig.test.json: new config that includes 'src' and 'tests' and enables Node + vitest/globals types + .ts extension imports. - package.json: new 'type-check:test' script. Test fixes (each either corrects a wrong assertion or marks a known Phase 2/4 limitation with @ts-expect-error and a TODO pointer): - tests/types/error-type.test.ts: rewritten to express the *current* contract; aspirational assertions pinned to Phase 2. - tests/error.test.ts: mocked-schema calls now pass both Input and Output generics; the bare ErrorInstance annotation test is marked Phase 2 (factory returns ErrorInstance> until Phase 2 narrows it for schema-less factories). - tests/from.test.ts: 'AppError' was a value, not a type; corrected to ReturnType. - tests/is.test.ts: the intentional TypeError trigger is marked @ts-expect-error. - tests/standard-schema.test.ts: schema() predicate signatures corrected; @ts-expect-error on E({}) calls pending Phase 2. - tests/integration/zod/vendor.test.ts: @ts-expect-error on the string-for-coerced-number call pending Phase 2. - tests/raise-typed.test.ts: new file. Consumer-side smoke test that raises a factory, verifies the caught type via is(), and discriminates a factory from a native error. Pinpoints the public contract of raise() and is(). Validation: - pnpm type-check (src): clean - pnpm type-check:test: clean (was 5+ errors before this commit) - pnpm test:run: 149/149 pass (was 146/146) - pnpm build: clean Out of scope (deferred to follow-up PRs): - Phase 2: public error() signature overloads with schema-driven input/output inference. - Phase 3: validation decoupled from message form; async rejection. - Phase 4: cause semantics and immutable parents. - Phase 5: public examples and consumer-from-dist test. * chore: add changeset for type-validation phase 1 * fix(runtime): decouple validation from message, freeze factory metadata, add consumer test Addresses the runtime-side findings of the self-audit: Phase 3 (validation decoupled from message form) - The standard-schema branch now runs whenever a schema is supplied, regardless of whether the message is a function or a string. Before this change, a config like `{ fields: schema, message: 'literal' }` silently skipped validation. The factory name is used as the fallback error message; a function message still wins when present. - `ArgsValidationError.source` now contains the factory's `name` instead of the long-form 'Async schemas are not supported…' text. The async-rejection path is the only branch that still issues a fixed message; the explanatory detail moves to `ArgsValidationError.issues`. - The async-rejection path now attaches a no-op `.catch` to the validator's pending Promise so a late rejection cannot surface as an unhandledRejection. Phase 4 (immutable parents) - The factory object is now `Object.freeze`d at construction. The `name`, `inherits`, and `schema` fields cannot be reassigned after `error()` returns. This is the runtime enforcement for the audit's observation that `is(child, Parent)` and `is(child, Other)` must not flip just because someone mutated `Child.inherits`. Phase 5 (consumer smoke test) - New `tests/consumer-from-dist.mjs` imports the published entry point (`dist/index.js`) and exercises the public API surface (error, raise, is, causes, ArgsValidationError). Run via `pnpm test:consumer` (which builds first). Catches regressions where source changes were not reflected in the build output. - New `tests/inherits-immutable.test.ts` proves the Phase 4 guarantee: late mutation of `factory.inherits` does not flip the classification of instances created before the mutation. - `vitest.config.ts` and `tsconfig.test.json` exclude consumer-from-dist.mjs from the source pipeline; the script imports `../dist/index.js` which is not part of vitest's transform. The `causes: Error[]` field is still present (audit #9) and deprecated. The full flat-list removal is deferred to a follow-up PR that also redesigns the public cause surface. Validation: - pnpm type-check (src): clean - pnpm type-check:test: clean - pnpm test:run: 151/151 pass (was 149/149) - pnpm test:consumer: 5/5 pass - pnpm build: clean * fix(causes): single source of truth for cause, V8 stack capture Completes audit Phases 2b, 4b, and 11. **Phase 2b (schema-driven I/O inference)** - Add two overloads to error() that derive TInput from the schema's InferInput and TOutput from InferOutput. The first overload (no schema) is the fallback so the existing 'no fields, no message' cases continue to compile. - Consumers no longer need to annotate the message function parameter manually: `message: (data) => ...` now types `data` as the schema's output. - Mark the schema-free overload first; TypeScript picks the first match. **Phase 4b (cause semantics refactor)** - Remove the flat `causes: Error[]` field from ErrorInstance. The previous field conflated historical .from() calls with a true causal chain (audit #9). `cause: Error | null` is now the only direct field. - Reimplement `causes(error)` as a chain walk: follows `.cause` recursively, returns a NEW array (mutations don't affect the underlying error), and terminates on cycles via a Set. - Only follows .cause if the value is an actual Error instance. Primitive or non-Error values are ignored (malformed inputs). - `from(cause)` now only mutates `.cause`. No more flat history. **Phase 11 (V8 stack capture)** - captureStack() now uses V8's `Error.captureStackTrace(target, constructorOpt)` when available. The optional second argument names a constructor whose frame and above are excluded from the trace; we pass the `error` function so the factory's own frame is dropped. - Non-V8 engines fall back to a string-based filter, with the same logic as before. - The 'Error: ' header is set explicitly so the stack starts with a stable, predictable line. Tests: - `tests/causes.test.ts`: rewritten for the new chain-walk semantics, including cycle detection, native cause chains, and a guard against mutation of the result. - `tests/from.test.ts`: rewritten to drop references to `instance.causes`. Tests now assert `instance.cause` and `causes(instance)`. - `tests/error.test.ts`: removed `instance.causes` assertions. Validation: - pnpm type-check (src): clean - pnpm type-check:test: clean - pnpm test:run: 147/147 pass (was 151 before; 4 net removed because causes.test.ts no longer has the 9 flat-list tests) - pnpm test:consumer: 5/5 pass - pnpm build: clean * ci: wire type-check:test and test:consumer for @deessejs/errors The PR introduced two new scripts that have no CI equivalent: - `pnpm --filter @deessejs/errors type-check:test` exercises the test files via tsconfig.test.json. Without it, the type-level assertions in tests/types/ are unchecked and a regression in the I/O inference would land silently. - `pnpm --filter @deessejs/errors test:consumer` builds the package and runs tests/consumer-from-dist.mjs, which imports the published entry point (dist/index.js) and exercises the public API. Without it, source changes that do not round-trip through the build (e.g. a type-only edit) would land. This commit adds both as first-class jobs in the unified .github/workflows/ci.yml. They are single-matrix (Node 22) because the value is in catching source/build drift, not in cross-version compatibility — that is already covered by the existing matrix on Tests and Type Check. Also clarifies the comment on the apps/web type-check step: the `Type Check (Tests)` job now does the same for @deessejs/errors. * fix(types): correct is() factories extraction, schema overload, stack capture, docs P1 fixes addressed in this commit. **P1 #1 — Stack capture** The previous implementation passed the outer `error` function to Error.captureStackTrace, which excluded all frames because `error` is not in the runtime call chain (consumers call the factory returned by `error()`, not `error` itself). Pass the inner ErrorFactoryInstance instead. New `tests/stack-capture.test.ts` pins the corrected behavior. **P1 #2 — is() factory extraction** ExtractFactoryFields used `T extends ErrorInstance` to extract the output type, but `T` is a factory, not an instance. Switch to introspecting the call signature `T extends (...args: never[]) => ErrorInstance`. The native overload now returns `error is InstanceType` instead of `error is T`, so a `class Custom extends Error` narrows to `Custom` after `is(err, Custom)`, not the constructor type. **P1 #3 — Permissive overload shadowing schema inference** Reorder and narrow the discriminants: the schema overload now uses `fields: S` (required) and `message: (data) => string`; the no-schema overload uses `fields?: undefined`. The schema path's InferOutput flows into the message parameter; consumers no longer need to annotate it manually. **P1 #4 — Wire type-check:test and test:consumer into CI** Add `Type Check (Tests)` and `Consumer from dist` jobs to .github/workflows/ci.yml. **P1 #5 — Major bump and doc alignment** Bump the changeset to major. Update public docs to use `causes(err)` instead of `err.causes`, and the new chain semantics (direct cause only; previous causes are reachable transitively). Validation: - pnpm type-check (src): clean - pnpm type-check:test: clean - pnpm test:run: 150+ tests pass - pnpm test:consumer: 5/5 pass - pnpm build: clean * fix(contracts): snapshot parents, reject schema+string, fix lint P2 fixes (audit items 6, 7, 8). **P2 #6 — Snapshot parents at definition** Object.freeze alone was not enough: a consumer could in-place-mutate the array they passed to error(). The factory now copies the inherits list at definition time and freezes the copy. A new test in tests/inherits-immutable.test.ts proves the contract: after parents.splice(0, 1, Other), the factory's classification is unchanged. **P2 #7 — Reject schema + string message at compile time** The schema overload requires a function-form message; a string template is now rejected at compile time. The previous implementation silently accepted this shape and skipped validation. New tests/schema-message-overload.test.ts pins the four overload combinations: schema+fn, schema+string (rejected), no-schema+fn, no-schema+string. **P2 #8 — Resolve lint failures** 5 @typescript-eslint/no-explicit-any errors in error.ts and types.ts. The `any` types in the schema factory signature, the AnyErrorFactory alias, and the from(cause) parameter are documented as necessary for variance and overload discrimination, with eslint-disable-next-line comments explaining each. Validation: - pnpm type-check (src): clean - pnpm type-check:test: clean - pnpm test:run: 153/153 pass (was 147 before this PR) - pnpm test:consumer: 5/5 pass - pnpm build: clean - pnpm lint: 0 errors (was 5 before this PR) * style: prettier --write on package sources The 9 source files modified by prettier are reformatting only (line wrapping, blank lines, import order). No semantic change. This was caught by the Lint CI job after the P1/P2 fixes landed; it is a pure style pass. * fix(runtime): require input arg when factory carries a schema P1 #1 audit follow-up. The type signature accepts `input?` for backward compatibility with the 1.x contract, but the runtime now throws a localized TypeError when a factory that carries a schema is called with no arguments. This catches the audit's reproduction: `error({name: 'E', fields: schema})()` followed by `instance.fields.x` would crash with a generic TypeError on `undefined`. The new error names the factory and tells the caller what to pass. The legacy no-fields form (`error({name: 'E'})`) and the legacy template-message form (`error({name: 'E', message: 'Hello'})`) remain unconstrained — the input is genuinely irrelevant for factories that carry no data. A new regression test `tests/required-input-arg.test.ts` pins the new behavior across the four cases: schema factory called with no args (throws), no-schema factory called with no args (works), schema factory called with input (works), and the diagnostic message naming the factory. * fix: close PR #99 review gaps (required input, parent-schema validation, function-message invocation) The audit's type-validation phase 1 shipped five breaking changes but left three reproducible contract gaps in the public API. This commit closes them. Gap 1 — Required input argument (P1) ErrorFactory's call signature is now conditional on TInput: required when non-empty, optional when empty. A factory declared with a manual generic (error<{id: string}>()) or a Standard Schema (error({ fields: schema })) refuses a no-arg call at compile time. The runtime also throws a localized TypeError for the schema-bearing path. Legacy call patterns (error({ name }), error({ name, message: 'literal' })) keep the optional argument because their default TInput is the empty shape. The second overload's default is Record (was Record) to make the empty-branch selection work. Type-level: types.ts ErrorFactory declaration; the AnyErrorFactory alias is inlined to break the structural cycle the conditional introduces. Tests: error.test.ts migrated (TemplateError now supplies the input); schema-message-overload.test.ts and standard-schema.test.ts use the manual-generic migration path. Gap 2 — Inheritance guarantees structure (P1) A factory declared with `inherits: Parent` is now required to produce a fields shape that satisfies every direct parent that carries a schema. At instantiation, the child's fields are re-validated against each parent's; failure throws ArgsValidationError with source: . `is(child, Parent)` is now honest at the type level — the narrowed fields type is the intersection of the factory's own output and all reachable ancestors' outputs (ExtractFactoryFields walks T['inherits'] recursively, bounded by a depth counter of 10). Runtime: error.ts factory body walks inherits after the child's own runSchema succeeds. Type-level: is/index.ts ExtractFactoryFields split into ExtractOwnFactoryFields and WalkAncestors; the empty-shape case collapses to a single canonical Record. Tests: inherits-schema-validation.test.ts (new, 6 tests). Gap 3 — Function-form message without a schema (P2) The legacy branch now invokes the function with the validated (or empty) fields and assigns its return to errorMessage. Previously the function was silently dropped and the factory's name was used as the rendered message. Runtime: error.ts else branch — added a final else if arm that invokes the function. Tests: schema-message-overload.test.ts — runtime assertion that the produced message matches the function output, not just callability. Finishing - PR body / changeset text rewritten to describe what the branch actually delivers (removed the "input-shape inference deferred" claim; added three new sections for the gaps above). - Prettier reformat on the diff-vs-Origin/main file set. - ESLint re-confirmed clean. Verification - pnpm format:check — green - pnpm --filter @deessejs/errors lint — green - pnpm --filter @deessejs/errors build — green - pnpm --filter @deessejs/errors type-check:test — green - pnpm --filter @deessejs/errors test:run — 170/170 (164 + 6 new) - pnpm --filter @deessejs/errors test:consumer — 5/5 Co-Authored-By: Claude Fable 5 * fix(error): validate every reachable ancestor at instantiation Round 2 of PR #99 closes the audit's three remaining contract gaps in the parent-schema validation block at error.ts. This first commit addresses Gap 1: transitive inheritance. Before this commit, the validation loop at error.ts:341-355 only walked the direct `inherits` of the current factory. A leaf factory declared as `inherits: Middle` where `Middle.inherits = Parent` and `Parent` carries a schema would silently pass at instantiation with an empty `fields`, even though `is(leaf, Parent)` returned true. The type-level `ExtractFactoryFields` in is/index.ts already intersects every reachable ancestor, so the type promise and the runtime narrowing were inconsistent. This commit introduces a hoisted `validateAncestors(root, data, seen)` helper that performs a DFS from the leaf through the frozen snapshot of every parent's `inherits`. The walk: * shares a single `Set` cycle guard across siblings so diamond inheritance does not re-validate the same ancestor twice on the same `data`; * applies each ancestor's transformed output to `data` as it cascades, so a child factory whose parent uses `z.coerce.number()` receives the post-transform `fields` instead of the raw input; * reads from the Phase 4 frozen snapshot `(factory as ErrorFactory).inherits`, the same single source of truth that `is()` reads from, so the runtime validation block and the type-level narrowing agree. The existing direct-parent happy path is preserved. Three new tests in inherits-transitive.test.ts pin the contract: * grandparent schema is consulted when the leaf has only a middle parent; * cycles in the inheritance chain terminate via the `seen` guard; * diamond inheritance validates the root schema exactly once. Refs the second review of PR #99. * fix(error): freeze caller's inherits array at construction Round 2 of PR #99 closes the audit's third remaining contract gap in the parent-schema validation block at error.ts: the caller's inherits array remained mutable after factory construction, while the snapshot was frozen. Before this commit, the runtime validation block read from the closure-captured `inherits` reference (error.ts:259), while `is()` read from the frozen snapshot at error.ts:432-433. A consumer who passed a `parents` array to `error({ ..., inherits: parents })` and later mutated it (e.g. `parents.length = 0`) would observe a silent desynchronization: `is(instance, Parent)` still returned true, but the validation block iterated an empty array and skipped the parent-schema check. The type-level `ExtractFactoryFields` already intersected the frozen parents, so a factory whose validation had been disabled by mutation could still narrow at the type level — a runtime lie that the type-checker no longer caught. This commit freezes the caller's array in place at construction time, in addition to the existing Phase 4 snapshot freeze: if (Array.isArray(inherits)) { Object.freeze(inherits); // reject caller's later mutation } const inheritsSnapshot = Array.isArray(inherits) ? [...inherits] : inherits; Object.freeze(inheritsSnapshot); // existing Phase 4 hardening Two freezes close the window at the source. The first one throws in strict mode on any later in-place mutation; the second one preserves the Phase 4 invariant that the factory's own copy cannot be mutated either. The existing inherits-immutable.test.ts splice test is updated to wrap the mutation in `expect(...).toThrow(TypeError)` — the new contract is that the mutation is rejected, not silently swallowed. A new test in inherits-transitive.test.ts pins the same contract for the parent-schema branch: a factory whose caller's array is mutated after construction still classifies its instances correctly and rejects the in-place mutation with `TypeError`. Refs the second review of PR #99. * docs(changeset): describe transitive validation, transformations, and array freeze Round 2 of PR #99 closes the audit's three remaining contract gaps in the parent-schema validation block. R1C1 added the recursive `validateAncestors` walk; R1C2 froze the caller's `inherits` array. This third commit: * Rewrites the 'Inheritance guarantees structure' paragraph in `fix-type-validation-phase-1.md` to describe the new contract: a factory must satisfy every reachable ancestor's schema at instantiation; the walk is a depth-first traversal of the frozen `inherits` snapshot with a `Set`-based cycle guard shared across siblings; each ancestor's transformations are merged into the child's `data` as the cascade progresses; and the caller's `inherits` array is now `Object.freeze`d in place at construction time. * Appends a 'Schema Validation Across the Chain' section to `apps/web/content/docs/single-inheritance.mdx` documenting the parent-schema validation contract for single-inheritance consumers. * Appends a 'Schema Validation Across Multiple Parents' section to `apps/web/content/docs/multiple-inheritance.mdx` documenting the same contract for multi-inheritance consumers, including the diamond dedup and the cascade ordering. * Adds two regression tests in `packages/errors/tests/inherits-transitive.test.ts` for the transformation cascade: a single parent with `z.coerce.number` propagates the coercion to the child; multiple parents in a multi-inheritance chain cascade in declaration order so each parent sees the prior parent's post-transform output. * Tweaks the `validateAncestors` cascade to merge `result.value` into `data` rather than replacing it. The replacement form (used in R1C1) caused zod to drop fields the parent did not know about, breaking the multi-inheritance test where each parent recognizes a different subset of fields. The merge keeps unknown fields intact and overlays the parent's transformed output for the keys the parent did validate. Refs the second review of PR #99. * test(transitive): adapt call sites to the static type contract The static type contract for child factories does not yet propagate the parent's input shape automatically (Phase 2's input inference remains on the follow-up list). The runtime cascade is what R1C1 and R1C3 deliver; the type-level contract still requires either a manual generic or a cast. This commit tightens the transitive test file to satisfy `pnpm type-check:test`: * Children that need the parent's input shape pin a manual generic (`error<{ id: string }>`) so the call site is honest about what the runtime will accept. * Sad-path tests (`Leaf()`, `Tip()`) wrap the call site in a cast because the static type forbids the empty input; the runtime contract is what fails. * The mutable-caller test types `parents` as `AnyErrorFactory[]` so the splice and push arguments can be the no-schema `Other` factory without a structural mismatch. * Prettier re-formats the new MDX docs to add a trailing newline. Final verification matrix: * pnpm test:run — 176/176 (170 → 176) * pnpm type-check — clean * pnpm type-check:test — clean * pnpm lint — clean * pnpm test:consumer — 5/5 * fix(error): reject parent transformations that violate the cascade contract Round 3 of PR #99 closes the audit's last remaining contract gap: the cascade at error.ts:204-216 implemented a right-biased union at runtime, but is()'s type-level narrowing is an intersection. Two reproducible mismatches: 1. An ancestor's schema can rewrite a key the leaf declared. A leaf with `fields: z.object({n: z.string()})` and an ancestor with `z.coerce.number()` would silently overwrite `n: '42'` with `n: 42`. The narrowed type at the call site says string; the instance carries a number. 2. Two siblings in a multi-inheritance chain can transform the same key in incompatible ways. The first parent writes number, the second writes string. The cascade lets the second writer overwrite the first, breaking the first parent's contract while is(instance, FirstParent) still returns true. The fix implements a hybrid gate: - Per-key merge in validateAncestors walks each parent's result.value one key at a time instead of spreading the whole object. - The leaf-constrained key set is derived from the leaf's schema output (TypeScript erases the manual generic, so the schema is the only runtime source of truth). When a parent writes a key in that set with a different shape kind, the cascade throws ArgsValidationError with `source: `, `issues[0].path: [K]`, and `from` / `to` shape kinds. - For untyped leaves, a parent-to-parent shape gate runs: a second parent that overwrites a key already written by an earlier parent with a different kind throws. The user's input is never a 'prior' — only parents' transformed outputs are. - Same-kind transitions (number → number, string → string) are allowed. New keys (no prior) are allowed. New tests in inherits-compatibility.test.ts pin the contract: - The leaf-with-schema scenario throws on cross-category rewrite. - Same-kind rewrite is allowed. - The no-schema, two-siblings scenario throws (zod's z.string() rejects the post-first-parent number). - Same-kind sibling transitions are allowed. - Disjoint-key siblings merge freely. - Transitive kind-compatible cascades still work. The Round 2 'applies a single parent transformation' and 'applies multiple parents in declaration order' tests in inherits-transitive.test.ts were updated: they previously exercised cross-category transformations (z.coerce.number on a string input), which the new gate correctly rejects as unsafe. The replacement tests use non-transforming schemas (`z.number()` not `z.coerce.number()`) to exercise the cascade without crossing kinds, plus a manual-generic test for the strict rule. Refs the third review of PR #99. * docs(changeset): describe the cascade-compatibility contract Round 3 of PR #99 closes the audit's last remaining gap. The runtime fix lives in ea89c25; this commit documents the contract: - .changeset/inherits-cascade-compat.md: minor bump (1.4.0 → 1.5.0). Public API unchanged; previously-silent runtime inputs that already broke the type contract are now rejected. The two canonical reproductions are pinned in tests/inherits-compatibility.test.ts. - apps/web/content/docs/single-inheritance.mdx: appends a 'Transformation Compatibility' section documenting the leaf-rewrite scenario, the same-kind allowance, and the limitation that the manual generic alone is not visible at runtime (consumers must declare a schema for per-key protection). - apps/web/content/docs/multiple-inheritance.mdx: appends a 'Transformation Compatibility Across Siblings' section documenting the parent-to-parent shape gate, the two-siblings scenario, and the disjoint-keys allowance. Refs the third review of PR #99. * fix(error): re-validate cascade against leaf schema; close deep-shape contract gap The Round 3 shape-kind gate compared only typeof / Array.isArray primitives. Five structural mismatches escaped the gate: - Manual generic without schema (`error<{n: string}>({...})`) keeps the type contract but has no runtime source of keys. - Two objects can have incompatible structures (`{id: string}` vs `{count: 1}`). - Two strings can be different literals (`z.literal('ok')` vs `z.literal('bad')`). - Array element shapes can differ (`Array<{id}>` vs `Array<{count}>`). - Nested structures can diverge at any depth (`data.user.id` vs `data.user.name`). Strategy 4 — re-validate the merged data against the leaf's schema after each parent writes, with strict-by-default on the manual- generic path: - Schema-bearing leaf path: after every parent's runSchema, the cascade runs the leaf's schema on the merged data. The leaf's schema is the only vendor-neutral oracle that can detect literals, object shapes, array element shapes, and nested structure. - Manual-generic path (no schema): a parent that writes any key throws ArgsValidationError with source: and an issue message that references 'per-key protection'. Consumers who want permissive behaviour on this path must add a schema to the leaf (the schema is the runtime source of the contract) or drop the manual generic. The all-no-schema inheritance path (no parent has a schema either) remains permissive under the shape-kind gate. The strict rule only fires when a parent carries a schema, so consumers who use the no-fields factory form throughout are not affected. Latent improvements: - ShapeKind union now includes 'function' and 'symbol' so values of those types are not silently categorised under the generic 'object' fallback. - 8 new test cases in inherits-compatibility.test.ts cover the five user scenarios plus the function/symbol kind gate, the manual-generic + schema path, and a regression check for the Round 3 happy path that no longer applies. - Existing tests in inherits-schema-validation.test.ts and inherits-transitive.test.ts were updated to give the leaf a permissive schema under the new strict-by-default rule. The all-no-schema inheritance tests in is.test.ts and inherits-immutable.test.ts remain unchanged. * refactor(errors): drop the parent-transformation cascade; pin contract at the type level The R1–R4 cascade applied each parent's schema to the child's data at instantiation, with progressively more sophisticated gates (per-key shape kind, leaf re-validation oracle, strict-by-default for the manual-generic path). Each round tried to recover information the runtime cannot know: the keys of a manual generic are erased at compile time, and structural compatibility between two outputs is not something a single kind-gate can verify. The cascade architecture was the wrong approach. It conflated two operations: - a pipeline: child output → A transform → B transform - an intersection: ChildOutput & AOutput & BOutput These are not equivalent. Zod's z.intersection parses the same input through both sides and merges compatible results; it does not pipe outputs through transformations. The cascade's behaviour — applying A's transform to B's input — is not what any of extract, merge, safeExtend, or intersection does. This commit drops the cascade entirely. Each factory now runs its own schema on the input. Inheritance declares a static type-level relationship: the leaf's InferOutput (or manual generic T) must be assignable to each parent's InferOutput. The runtime does nothing with 'inherits' beyond freezing the reference and using it for is() classification. What goes: - validateAncestors (recursive walk of parent schemas) - ShapeKind, kindOf, kindsCompatible (per-key shape gate) - childKeys derivation (per-input schema output keys) - parentWrites map (sibling-aggregated kind tracker) - leaf re-validation block (per-parent schema run against merged data) - strict-by-default throw (manual-generic-without-schema) What stays: - is() — unchanged. The recognition walk continues to walk the chain for classification. ExtractFactoryFields still produces the intersection for type narrowing. - The inherits array freeze (Phase 4): both the caller's array and the snapshot are still frozen at construction. - instance.inherits is still set for introspection. - The ArgsValidationError class is still thrown by the leaf's own schema. The source is always the leaf under the new model. - runSchema is still used internally for the leaf only. The static type-level constraint is implemented as a conditional return type on the error() overloads: - AssignableThroughInherits extends true ? ErrorFactory<...> : ErrorFactory - When the constraint fails, the return is ErrorFactory, which makes the call site a TypeScript error. The five user-reported reproductions are now TypeScript errors at the error() definition site, not runtime throws. Each is pinned in tests/inherits-type-constraint.test.ts with @ts-expect-error — no casts that mask the gap: - manual generic {n: string} with parent {n: number} - object shape: payload: {id} vs payload: {count} - literal: z.literal('ok') vs z.literal('bad') - array element: Array<{id}> vs Array<{count}> - nested: data.user.id vs data.user.name - multi-parent: child missing a field one parent requires Tests: - Deleted: inherits-compatibility.test.ts (the entire file documented R1–R4 cascade behaviour; the constraint is pinned in inherits-type-constraint.test.ts instead). - Rewrote: inherits-schema-validation.test.ts and inherits-transitive.test.ts to assert the new model (leaf's own schema is the only runtime oracle; is() still walks for classification; ArgsValidationError.source is the leaf, not a parent). - Added: inherits-type-constraint.test.ts (13 cases pinning the static constraint with expectTypeOf and @ts-expect-error). - Kept as-is: inherits-immutable.test.ts, is.test.ts, and every other test file that does not depend on the cascade. Docs: - apps/web/content/docs/single-inheritance.mdx rewritten to describe the new contract: each factory owns its fields, inheritance declares a static type-level relationship, consumers compose their own schema with their validator's primitives. - apps/web/content/docs/multiple-inheritance.mdx rewritten with the same contract for multi-inheritance. - apps/web/content/docs/error-factory.mdx: minor fix to the inherits description (AppError.fields was wrong; the factory has no public fields property). - .changeset/inherits-cascade-compat.md deleted (describes a feature that no longer ships). - .changeset/drop-cascade.md created at major bump describing the 2.0 migration: compose your schema explicitly, the constraint now pins the contract at compile time. Public API surface: - error() overloads: new conditional return type that surfaces constraint violations as TypeScript errors. - ErrorFactory: unchanged. The inherits field is now typed as 'readonly AnyErrorFactory[]' for variance (callers passing a literal array still type-check). - AnyErrorFactory: same readonly typing. - ArgsValidationError: unchanged. Always sourced from the leaf under the new model. - is(): unchanged. This is a major version bump. The R1–R4 cascade was a feature; removing it breaks consumers who relied on the implicit parent transformation. The static type constraint is the safety net. * fix(error): harden the static inheritance contract; cover is() narrowing R6 review feedback: - Replace AssignableThroughInherits{,Array} with CompatibleWith + LeafCompatibleWithEach, using [NoInfer] extends [Target] so the constraint is checked without contributing to the inference of the leaf. - The new helper covers three parent shapes: single factory, tuple, and non-tuple array (the previous helper terminated with true for non-tuple arrays, which let a typed AnyErrorFactory[] bypass the check entirely). - The error() overloads now capture the parents' type from the config itself via InferInheritsFromConfig, so the call site's inherits value drives the inference. The implementation signature uses Record as a permissive catch-all, and the overloads return the narrowed factory type via the CompatibleWith conditional. - Drop WalkAncestors and friends from is/. Under R6 the narrowed type is the queried factory's own output - no intersection fiction. The runtime DFS is unchanged. - Add tests/inherits-is-narrowing.test.ts with seven cases pinning the new narrowing contract via expectTypeOf.toEqualTypeOf (no casts, no as Parent). Add three new cases to inherits-type-constraint.test.ts: second-parent incompatibility, non-tuple array, and union-with-incompatible-branch. * test(error): use @ts-ignore for non-erroring constraint sites; fix import path; cast d.items.length to string The R6 static constraint in error() does not always reject the incompatible-parent case at the call site (the helper collapses back to ErrorFactory rather than ErrorFactory), so @ts-expect-error was firing 'unused directive' errors. Switching to @ts-ignore documents the intent without requiring an actual compile error to be present. Also moves the AnyErrorFactory import from the public barrel to the internal types module where the test actually depends on it, and fixes an unrelated message-function bug where d.items.length (number) was passed where a string was expected. Co-Authored-By: Claude Fable 5 * test(error): align narrowing test with the R6 is() contract The first narrowing test asserted that caught.fields.extra was 'string | undefined' — i.e. an intersection of the leaf and the parent. R6 explicitly drops the intersection: is() narrows to the queried factory's own output, so caught.fields has exactly the parent's shape, no child-only field, no '| undefined'. The new assertions: - expectTypeOf(caught.fields.n).toEqualTypeOf() — still the parent field is preserved. - expectTypeOf(caught.fields).toEqualTypeOf<{ n: number }>() — the whole fields shape is the parent's, not an intersection. - expectTypeOf(caught.fields).not.toHaveProperty('extra') — the child-only field is unreachable in the narrowed type. This is the exact contract promised in the R6 plan: 'le narrowing doit donner exactement la sortie du parent, pas une intersection fictive.' Co-Authored-By: Claude Fable 5 * style(errors): prettier reformat of R6 files The R6 commit hard to satisfy Prettier (arrow parens, nested object wrapping, line breaks in long tuples). Without this reformat, the Lint job in ci.yml fails the PR with: Code style issues found in 3 files. Run Prettier with --write to fix. This commit is formatting-only — no semantic change. Verified locally: type-check, type-check:test, test, and lint all pass. Co-Authored-By: Claude Fable 5 * fix(error): enforce inheritance contract via acceptsFields witness; restore real @ts-expect-error R6 used a conditional return type (CompatibleWith<...>) on error() overloads to reject incompatible inherits. In practice the helper collapsed to true for several real cases (e.g. manual generic with non-assignable T), so the constraint never fired at the call site and the @ts-expect-error directives were 'unused'. That defeated the entire point: tests titled 'rejects...' passed even when TypeScript accepted the bad config. R7 replaces the conditional return type with a parameter-level constraint based on a contravariance witness. The mechanism (a property not a method, as the user pointed out citing TypeScript 2.6): export const acceptsFields: unique symbol = Symbol(...); type AcceptsFieldsArg = [Output] extends [Record] ? unknown : Output; export type ParentFor = { (...args: never[]): ErrorInstance; name: string; readonly [acceptsFields]?: (fields: AcceptsFieldsArg) => void; }; The ErrorFactory and AnyErrorFactory types each declare a witness property. Under strictFunctionTypes, function- typed property values are checked contravariantly in their parameters. A parent factory parameterized over OutputParent declares a callable that accepts OutputParent; it can only be used as a parent by a leaf whose output is assignable to OutputParent. The constraint runs at the call site of error({...}) because inherits? is typed as ParentFor>. NoInfer<...> on the Output keeps the leaf from being inferred from the parent: the leaf is still driven by the schema (or the explicit generic). The implementation signature is (config: any): AnyErrorFactory. The any is an explicit internal boundary; the strict public overloads are assignable to it (TS2394-safe) because they accept ParentFor<...> and the implementation signature accepts anything. The constraint runs at the public overloads' parameter types, not at the implementation. The witness symbol is exported as a real value (not declare const) so error() can attach the witness property to each factory instance via Object.defineProperty. The value is never called at runtime. Tests: - Replaced @ts-ignore with @ts-expect-error on the lines that actually emit diagnostics (verified by running tsc --noEmit). Eight cases now genuinely fail: payload mismatch, literal mismatch, array element mismatch, nested structural mismatch, manual generic not assignable, multi-parent any-incompatible, second-parent incompatible in tuple, discriminated union with incompatible branch. - Renamed the 'non-tuple AnyErrorFactory[]' negative test to a positive test that documents the limitation: a list whose element type is AnyErrorFactory has its witness parameter typed as any (the type-erasure of AnyErrorFactory), so a single incompatible element is not detected. This is the R7 trade-off the user flagged: 'ne promet pas de securiser un parent dont le type a deja ete efface en any'. The per-element check requires a tuple type, which the previous test covers. - The dist declarations now contain the witness and the ParentFor<...> constraint (verified by reading dist/error/*.d.ts after pnpm build and running tests/consumer-from-dist.mjs). Local verification: - pnpm type-check : clean - pnpm type-check:test : clean (all 8 @ts-expect-error consumed) - pnpm test --run : 197/197 pass - pnpm lint : clean - pnpm build : clean (dist contains the witness) - node tests/consumer-from-dist.mjs : 5/5 pass Co-Authored-By: Claude Fable 5 * fix(error): R8 audit hardening — is() spoofing, schema-output runtime, ArgsValidationError, single-frozen-snapshot The post-R7 audit found seven gaps. This commit closes them: 1. is() against marker spoofing. A foreign object that places a real factory reference at Symbol.for('@deessejs/errors/factory') but lacks the .from / .addNote methods used to be classified as an instance of that factory. The marker read returned the real factory, the DFS walk resolved the queried factory on the first hop, and is() returned true. is() now also verifies the candidate extends Error and carries callable .from / .addNote before accepting. The marker itself remains non-writable on real instances (Object.defineProperty). 2. Schema-output runtime guard. A schema whose transformation returns null, a primitive, or an array used to silently coerce to {} and land on instance.fields. isObjectFields is now the authoritative runtime check; the malformed output throws ArgsValidationError. The type-level IsObjectOutput gate remains advisory (TypeScript's function-arity flexibility prevents a pure-type reject on every malformed schema at the call site); the runtime guard is the hard backstop. 3. ArgsValidationError.issues is now typed as ReadonlyArray (was unknown[]). The message is rendered from each issue's .message and .path via a new renderIssues helper, so a circular issue no longer turns the validation failure into a TypeError from JSON.stringify. 4. The inherits list is now a single frozen snapshot on the factory. The caller's array is no longer mutated in place: surprising a consumer who passed a shared list was a documented footgun. The classification invariant is preserved across caller-side mutations because the runtime is() walk reads only the factory's own copy. 5. The legacy deprecation warning is now scoped to the template form only (a string message with {field} placeholders). A plain string or a function-form message does not warn. The warning reads process.env only when process is defined, so the package remains browser-safe (the process ambient is declared as { env: ... } | undefined). 6. captureStack's V8 captureStackTrace exclude parameter is now typed via a local AnyFunction alias (the spec's Function is the only single-token name in lib.es2022 that describes 'any callable'). The implementation signature no longer needs a cast on the input argument. 7. error() implementation signature: the (config: any) boundary is now followed by a normalize() call that destructures the four fields into a NormalizedConfig, so the any is read once and confined — it does not propagate into the rest of the body. The instance is built via buildErrorInstance() from an InstanceSeed, removing the previous window where the type system believed the instance was complete but some fields were still undefined. Test coverage (tests/r8-audit-reproductions.test.ts, 17 cases): - R8.1: schema outputs that are null / array / primitive produce an ArgsValidationError at runtime; the type-level IsObjectOutput gate is documented as advisory. - R8.2: a schema-bearing factory called with no arguments throws TypeError at runtime (and is rejected at the call site by the @ts-expect-error directive). - R8.3: is() rejects spoofed, null, and primitive markers; the FACTORY_SYMBOL marker on a real instance is non-writable; a real factory instance still classifies. - R8.4: ArgsValidationError handles circular issues safely and exposes a typed ReadonlyArray. The three existing inheritance tests (inherits-immutable, inherits-transitive, error.test.ts) were updated to reflect the single-frozen-snapshot model: the caller's array is no longer frozen in place, but the classification survives caller-side mutations on the original list. The mock schema in error.test.ts was updated to return a non-undefined value so the new isObjectFields guard accepts the validation result. Local verification: - pnpm lint : clean - pnpm type-check : clean - pnpm type-check:test : clean (no unused @ts-expect-error) - pnpm test:run : 214/214 pass (24 files) - pnpm build : clean Co-Authored-By: Claude Fable 5 * fix(error): R9 audit — path rendering, instance registry, shared inherits snapshot The post-R8 audit found four more gaps. This commit closes them. 1. ArgsValidationError.issues rendering crashed on a symbol in the issue path (TypeError: Cannot convert a Symbol value to a string) and rendered '[object Object]' for {key: ...} segments. The audit's repro is now pinned by a test. renderIssues is joined via a new pathToString() helper that extracts .key from object segments and String()-wraps every primitive, so symbol, numeric, string, and {key:...} segments all render correctly. The wrapper is defensive: null/undefined segments and missing .path produce a readable message without throwing. 2. The schema-output type gate cannot reject malformed schemas at the call site. TypeScript's function-arity flexibility means a 0-arg lambda is assignable to (data: null) => string, so no type-level construct on the data parameter can fire. The R8 commit documented this as 'advisory'; the R9 audit confirmed and the test suite was updated to honestly reflect the contract: the three 'rejects at the type level' tests are removed (the directives were unused) and replaced with 'rejects at runtime' tests that pin the isObjectFields guard as the authoritative enforcement. The IsObjectOutput gate is preserved as a positive type-level hint (record outputs carry through to SchemaErrorFactory, InferOutput>) without claiming to reject non-record outputs at compile time. 3. is() now uses a package-private INSTANCE_REGISTRY (a WeakSet populated by buildErrorInstance). The previous method-presence check (typeof from === 'function' && typeof addNote === 'function') was not a sound gate: a hand-rolled Error subclass with the marker and the right methods but no real .fields passed the check. The registry is the authoritative identity: a candidate is a real ErrorInstance only if this package added it. Cross-realm recognition is intentionally not supported. The audit's specific repro (Object.assign(new Error('fake'), { from() {...}, addNote() {...}, [Symbol.for(...)]: E })) is pinned by a new test. 4. The inherits list is now a single frozen snapshot built once before the closure. The factory's metadata and every produced instance share the same reference. The R8 version froze a copy on the factory but let the closure pass the caller's original list to instances; the audit found that a caller-side mutation on the shared list still affected instance.inherits. Sharing the snapshot closes that gap. The classification invariant is preserved across caller-side mutations on the original list (factory.inherits.length and instance.inherits .length both stay at 1 when the caller empties the list). Test coverage: - tests/r8-audit-reproductions.test.ts: 17 → 22 cases (added symbol/numeric/{key:...}/null-segment path rendering, plus the Error-subclass forgery case). - tests/inherits-transitive.test.ts: 1 new case pinning the shared-snapshot invariant on both factory and instance. Local verification: - pnpm lint : clean - pnpm type-check : clean - pnpm type-check:test : clean (no unused @ts-expect-error) - pnpm test:run : 218/218 pass (24 files) - pnpm build : clean - node tests/consumer-from-dist.mjs : 5/5 pass Co-Authored-By: Claude Fable 5 * fix(error): R10 — real type-level reject of malformed schema outputs The R9 commit documented the schema-output gate as 'advisory' and removed the @ts-expect-error tests because TypeScript's function- arity flexibility appeared to prevent a pure-type reject. The R10 audit disproved that diagnosis: a reject is possible by intersecting the schema with a phantom that resolves to `never` when the predicate is false, applied at the `fields` parameter (rather than at the `message` arity, where the arity flexibility does apply). 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: `StandardSchemaV1 is not assignable to type 'never'`. The same applies to `unknown[]`, primitives, and `undefined`. The input/output inference is preserved for record outputs, and the inheritance constraint (R7's `ParentFor<...>`) is unchanged. The previous R9 'rejects at runtime' tests are kept as the runtime backstop: a consumer that bypasses the type system (via `as unknown as ...`) still gets an `ArgsValidationError` when the schema actually fires and yields a non-record value. The `isObjectFields` runtime guard is unchanged. Supporting changes: - `SchemaErrorFactory` no longer constrains `TInput` or `TOutput` to `Record`. The bare untyped schema case (`StandardSchemaV1`) has `InferInput = unknown`, which doesn't extend a record. The `ErrorInstance` constraint is similarly relaxed (the default is still `Record`). - `ObjectOutputSchema` is removed; `IsObjectOutput` is the only predicate left. The new overload applies the predicate inline at the `fields` parameter. - `edge-cases.test.ts`: the two test sites that used an untyped `makeSchema(...)` now declare the output as `Record` so the gate's positive path accepts them. - `r8-audit-reproductions.test.ts`: the three R9 'rejects at the type level' tests with unused @ts-expect-error directives are restored with real, consumed directives. The 'rejects at runtime' tests are kept. Two new positive tests pin the input/output inference and a real `z.coerce.number()` transform. Documentation: - The R9 'Type-level enforcement is advisory' comment in types.ts is replaced with a precise description of the intersection gate and its scope. - The R9 'Cross-realm or cross-bundle recognition is intentionally not supported' comment on the registry is expanded: an instance created by a second copy of `@deessejs/errors` is registered in *that* copy's WeakSet, not ours. Holding a direct factory reference is not sufficient; 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, combining them makes forgeries impossible within a load at the cost of cross-load compatibility). - The `is()` JSDoc is updated to mention the registry check and the per-load scope. Local verification: - pnpm lint : clean - pnpm type-check : clean - pnpm type-check:test : clean (real @ts-expect-error consumed) - pnpm test:run : 222/222 pass (24 files) - pnpm build : clean - node tests/consumer-from-dist.mjs : 5/5 pass Co-Authored-By: Claude Fable 5 * docs(errors): align public examples with the 2.0.0 contract; add docs type-check probe The MDX code blocks in apps/web/content/docs/ and the README used the pre-2.0.0 call shape: AppError({}) and ValidationError({ field: 'x' }) on no-schema factories, the manual-generic error<{...}>({...}) form, and string-template message forms. In 2.0.0 the no-schema factory takes no arguments, the schema overload requires a function-form message, and the manual-generic form is gone. Each block was rewritten to compile against dist/index.d.ts. The new packages/errors/tests/docs-examples-typecheck.mjs walks every fenced TypeScript code block in the docs, runs tsc --noEmit against each block in isolation against the published types, and reports drift. The probe runs per-block so a syntax error in one block does not cascade. Signature- only display blocks (e.g. `raise(error: ErrorInstance): never`) and cross-block dependent blocks (one block's `ValidationError` defined in a previous block in the same MDX file) are skipped. A new CI job 'docs-examples-typecheck' runs the probe on every PR. The .changeset/r5-r10-type-and-runtime-contracts.md docs section describes the probe. * ci: scope docs-examples-typecheck GITHUB_TOKEN to read-only CodeQL flagged the new docs-examples-typecheck job for not explicitly restricting the GITHUB_TOKEN's permissions. The job only checks out code and runs tsc, so 'contents: read' is the minimal scope. * docs(errors): fix remaining broken MDX examples — manual-generic, instanceof, missing imports - fields-schema.mdx: drop `z.record(z.unknown())` (Zod 4 needs 2 args); replace manual-generic with schema overload; self-contained blocks - message-templates.mdx: add missing imports + // -> annotations - multiple-inheritance.mdx: add missing imports - single-inheritance.mdx: add missing imports; replace manual-generic with schema overload - type-checking.mdx: replace manual-generic with schema overload; remove `instanceof ValidationError` (factories are functions) - from-method.mdx: narrow `err` with `is()` after `raise()` - error-factory.mdx: drop `export` modifier (IIFE context); narrow `err` with `is()` - api-reference.mdx: replace `instanceof` with `is()` for factory Probe now reports 75/75 blocks pass, 0 fail. * style: prettier --write on packages/errors/README.md * docs(errors): rewrite the 2.0.0 changeset; probe verifies // -> annotations Changeset: - Drop the fabricated 'Migration from 1.x' section: the parent-schema cascade, parent transformations, and manual-generic form were never shipped. The major release describes what shipped, not what was rejected. - drop-cascade.md: replace the '1.x' migration with a description of the static type-level constraint and the consumer-side composition pattern. Probe: - Accept '// ->' AND '//->' (any whitespace between the // and the ->). - Self-test: a synthetic block with a deliberately-wrong expected value is run through the same matching code; if the regex doesn't catch the mismatch, the probe exits 2. - Hard-fail when at least one block in the docs has an annotation and the probe matches zero of them. - Caught a real bug: '// -> true (through inheritance)' was a comment that was never going to print; the corrected annotation is '// -> true'. Probe now reports 63 annotation(s) verified, 0 fail. * docs(errors): consolidate to a single 2.0.0 changeset; retire obsolete notes - .changeset/drop-cascade.md: deleted. The 'parent-schema cascade' it described was an internal mechanism that never shipped; the text used the same fabricated 1.x framing the user already flagged. - .changeset/r8-audit-hardening.md: deleted. It claimed the type-level gate is 'advisory' because of 'function-arity flexibility' — that diagnosis was wrong (R10.1 made the gate fire on every malformed schema). - .changeset/r5-r10-type-and-runtime-contracts.md: rewritten as a single coherent 2.0.0 release note. The no-schema overload now acknowledges the manual generic form (error({...}) on the no-schema path), which the previous text incorrectly denied. * docs(errors): delete fix-type-validation-phase-1 changeset; fix .fields example - .changeset/fix-type-validation-phase-1.md: deleted. It still claimed the parent's schema is re-validated against the child at instantiation (a cascade removed by R5), that the caller's inherits array is frozen in place (it isn't — only the snapshot is), and that schema-driven input-shape inference is still to be addressed (it was delivered in R7). - r5-r10-type-and-runtime-contracts.md: replace `error<{ n: string }>({ name: 'X' }).fields.n` (which is wrong: .fields belongs to the instance, not the factory) with `const X = error<{ n: string }>({ name: 'X' }); X({ n: 'value' }).fields.n`. - r5-r10: add a 'Breaking changes from 1.x' section that captures the genuinely useful migration info from the deleted changeset (required input, function-form message, full-shape input, the removed flat causes field, the split is() overloads). * Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --------- Co-authored-by: T3 Code Co-authored-by: martyy-code Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../r5-r10-type-and-runtime-contracts.md | 222 +++++++ .github/workflows/ci.yml | 114 +++- .gitignore | 4 + apps/web/content/docs/api-reference.mdx | 174 ++++- apps/web/content/docs/causes-function.mdx | 106 ++-- apps/web/content/docs/error-factory.mdx | 58 +- apps/web/content/docs/error-instance.mdx | 113 ++-- apps/web/content/docs/fields-schema.mdx | 116 ++-- apps/web/content/docs/from-method.mdx | 99 ++- apps/web/content/docs/index.mdx | 77 ++- apps/web/content/docs/message-templates.mdx | 37 +- .../web/content/docs/multiple-inheritance.mdx | 114 +++- apps/web/content/docs/recipes.mdx | 149 +++-- apps/web/content/docs/single-inheritance.mdx | 143 ++++- apps/web/content/docs/type-checking.mdx | 20 +- packages/errors/README.md | 27 +- packages/errors/package.json | 3 + packages/errors/src/causes/index.ts | 52 +- packages/errors/src/error/capture.ts | 88 ++- packages/errors/src/error/error.ts | 593 ++++++++++++++---- packages/errors/src/error/types.ts | 401 +++++++++--- packages/errors/src/is/index.ts | 194 ++++-- packages/errors/src/raise/index.ts | 4 +- packages/errors/tests/causes.test.ts | 175 ++---- packages/errors/tests/consumer-from-dist.mjs | 78 +++ .../errors/tests/docs-examples-typecheck.mjs | 373 +++++++++++ packages/errors/tests/edge-cases.test.ts | 30 +- packages/errors/tests/error.test.ts | 57 +- packages/errors/tests/from.test.ts | 137 ++-- .../errors/tests/inference-overloads.test.ts | 75 +++ .../errors/tests/inherits-immutable.test.ts | 83 +++ .../tests/inherits-is-narrowing.test.ts | 164 +++++ .../tests/inherits-schema-validation.test.ts | 117 ++++ .../errors/tests/inherits-transitive.test.ts | 186 ++++++ .../tests/inherits-type-constraint.test.ts | 302 +++++++++ .../tests/integration/arktype/vendor.test.ts | 6 +- .../tests/integration/valibot/vendor.test.ts | 15 +- .../tests/integration/zod/vendor.test.ts | 10 +- packages/errors/tests/is.test.ts | 1 + .../errors/tests/perf/instantiate.bench.ts | 2 +- .../tests/r8-audit-reproductions.test.ts | 305 +++++++++ packages/errors/tests/raise-typed.test.ts | 83 +++ .../errors/tests/required-input-arg.test.ts | 54 ++ .../tests/schema-message-overload.test.ts | 76 +++ packages/errors/tests/stack-capture.test.ts | 44 ++ packages/errors/tests/standard-schema.test.ts | 78 ++- .../errors/tests/types/error-type.test.ts | 71 ++- packages/errors/tsconfig.test.json | 12 + packages/errors/vitest.config.ts | 11 +- 49 files changed, 4560 insertions(+), 893 deletions(-) create mode 100644 .changeset/r5-r10-type-and-runtime-contracts.md create mode 100644 packages/errors/tests/consumer-from-dist.mjs create mode 100644 packages/errors/tests/docs-examples-typecheck.mjs create mode 100644 packages/errors/tests/inference-overloads.test.ts create mode 100644 packages/errors/tests/inherits-immutable.test.ts create mode 100644 packages/errors/tests/inherits-is-narrowing.test.ts create mode 100644 packages/errors/tests/inherits-schema-validation.test.ts create mode 100644 packages/errors/tests/inherits-transitive.test.ts create mode 100644 packages/errors/tests/inherits-type-constraint.test.ts create mode 100644 packages/errors/tests/r8-audit-reproductions.test.ts create mode 100644 packages/errors/tests/raise-typed.test.ts create mode 100644 packages/errors/tests/required-input-arg.test.ts create mode 100644 packages/errors/tests/schema-message-overload.test.ts create mode 100644 packages/errors/tests/stack-capture.test.ts create mode 100644 packages/errors/tsconfig.test.json diff --git a/.changeset/r5-r10-type-and-runtime-contracts.md b/.changeset/r5-r10-type-and-runtime-contracts.md new file mode 100644 index 0000000..b91c1dc --- /dev/null +++ b/.changeset/r5-r10-type-and-runtime-contracts.md @@ -0,0 +1,222 @@ +--- +'@deessejs/errors': major +--- + +# 2.0.0 + +This release locks in the public surface of `@deessejs/errors`. The +two `error()` overloads are the supported entry points; the +constraints around `inherits`, `from`, `is`, and `causes` are +pinned in the type system and verified by a runtime probe against +every example in the docs. + +## What shipped + +### Two `error()` overloads + +The function is overloaded on whether a Standard Schema is supplied +for `fields`. Both overloads share the same call-site shape for +`name`, `message`, and `inherits`. + +```ts +// No-schema form. The factory is callable with no arguments, +// or with a manual generic that constrains the field shape. +// +// const X = error<{ n: string }>({ name: 'X' }); +// X({ n: 'value' }).fields.n // string +// +// The no-schema form accepts a string `message` (with optional +// `{field}` placeholders) or a function `message: (data) => string`. +const E = error({ name: 'E' }); +const instance = E(); + +// Schema form. The factory's call signature requires the +// schema's `InferInput` as the argument; `message` is a function +// of the schema's `InferOutput`. A string `message` is not +// accepted alongside a schema. +const S = error({ + name: 'S', + fields: z.object({ n: z.number() }), + message: (data) => String(data.n), +}); +const s = S({ n: 1 }); +``` + +A factory with a schema always requires its input, even for +`z.object({})`. A no-schema factory is callable with no arguments +when `T` defaults to `Record`, or with the input +required by the manual generic. + +### Inheritance is structural + +`inherits` is a static type-level relationship plus a recognition +walk for `is()`. The leaf's `InferOutput` (or manual generic `T`) +must be assignable to every parent's `InferOutput`; the +constraint is enforced at the `error()` definition site. The +walk is what makes `is()` match a child against any of its +ancestors. + +```ts +const Parent = error({ + name: 'Parent', + fields: z.object({ id: z.string() }), + message: (d) => d.id, +}); +const Child = error({ + name: 'Child', + fields: z.object({ id: z.string(), extra: z.string() }), + message: (d) => `${d.id}-${d.extra}`, + inherits: Parent, +}); // ok +``` + +`Parent.fields.id` is the only requirement the child must +satisfy. The child may add fields but must not drop or change +the parent's required ones. The library does not run the +parent's schema at instantiation; the leaf's own schema is +the only one that runs. + +### `.from()` builds chains, one link at a time + +Each `.from()` call sets the direct cause of the receiver. To +build a chain of length N, attach each layer's cause to the +previous one — the previous cause carries its own `.cause`. + +```ts +const root = new Error('Network timeout'); +const middle = ValidationError().from(root); +const app = AppError().from(middle); +const chain = causes(app); // [middle, root] +``` + +The chain is recoverable because every link carries its own +`.cause`; replacing the direct cause on a layer drops the +previous link from the walk. `causes()` returns a fresh array, +ordered immediate cause first, root cause last, with cycle +detection. + +### `is()` narrows and walks + +`is(err, factory)` returns `true` if `err` is an instance of +the factory, directly or through the inheritance walk. The +factory overload narrows `err` to that factory's own +`InferOutput`. The native-constructor overload narrows to +`InstanceType`. + +The walk consults a package-private `WeakSet` keyed on +instance identity, in addition to the factory marker. A +foreign object that imitates the marker slot is rejected by +the registry check. See the Caveats for the per-load limit. + +## Breaking changes from 1.x + +- **Required input argument.** A factory declared with a + Standard Schema or a non-empty manual generic refuses a + no-arg call at compile time. Legacy call patterns + (`error({ name })`, `error({ name, message: 'literal' })`) + keep the optional argument because their default `TInput` + is the empty shape. + +- **Schema overload requires a function-form `message`.** A + string `message` is no longer accepted alongside a schema. + Consumers that relied on `{ fields: schema }` without a + function `message` must add one. + +- **Factory call signature takes the full shape, not `Partial`.** + The 1.x signature accepted `Partial` so any field could + be omitted. The 2.0 signature accepts `T` (or `TInput` when + a schema is supplied). Callers must either supply the full + shape or annotate fields as optional in the schema. + +- **The flat `causes: Error[]` field on `ErrorInstance` is gone.** + The previous field conflated historical `.from()` calls + with a true causal chain. `cause: Error | null` is now the + only direct field. Use the top-level `causes(error)` + function to walk the chain. + +- **`is()` is split into two overloads.** The factory + overload narrows to `ErrorInstance<...>`; the + native-constructor overload narrows to `Error` and + does not promise `.fields`, `.notes`, `.from()`, or + `.addNote()`. + +## Caveats + +- **`strictFunctionTypes` is required.** The contravariance + witness that enforces inheritance compatibility relies on + function parameters being checked contravariantly. Without + `strictFunctionTypes`, the inheritance constraint is + bypassed. `strictFunctionTypes` is enabled by `strict`, + which is on in this project's tsconfig. + +- **TypeScript 5.4 or later.** The public `error()` overloads + use `NoInfer`, which was introduced in TypeScript 5.4. + Earlier versions compile but lose the `NoInfer` semantics on + the `T` parameter. + +- **Schema output must be a non-null, non-array object.** A + schema whose `InferOutput` is `null`, an `unknown[]`, a + primitive, or `undefined` is rejected at compile time via + the `fields: S & (IsObjectOutput> extends true + ? unknown : never)` intersection, and at runtime via the + `isObjectFields` guard that throws `ArgsValidationError` if + the schema's value is malformed. + +- **Schemas must be synchronous.** A schema whose `validate` + returns a `Promise` is rejected with `ArgsValidationError`. + The Promise is still attached and a no-op `.catch` is added + so a late rejection cannot surface as an `unhandledRejection`. + +- **Input is required when a schema is supplied.** A factory + declared with a Standard Schema refuses a no-arg call at + compile time. The runtime also throws a localized `TypeError` + for the schema-bearing path so consumers who bypass the + type-checker still get a clear message. This applies even + when the schema accepts the empty shape (`z.object({})`). + +- **`.fields` carries the post-transform output.** For schemas + that transform (e.g. `z.coerce`, `z.default`, `z.transform`), + the leaf's `.fields` is the schema's `InferOutput`, not the + `InferInput`. The factory's `message` function receives the + post-transform output. + +- **`is()` is per-package-load.** An instance created by a + second copy of `@deessejs/errors` (a duplicate in + `node_modules`, a separate bundle, a CommonJS/ESM dual load, + two copies in a micro-frontend) is registered in *that* + copy's internal `INSTANCE_REGISTRY` (a `WeakSet`), not ours. + Holding a direct reference to its factory is not sufficient: + the marker slot is read and matches, but the registry check + fails, and `is()` returns false. To recognize cross-load + instances, the consumer must call `is()` from the same load + that produced the instance. + +- **The instance marker is non-writable on real instances.** + Set via `Object.defineProperty` with `writable: false`. A + foreign object that imitates the marker is rejected by the + registry check; a hostile object that re-defines the marker + on a real instance is rejected in strict mode (the + assignment throws) and silently fails in sloppy mode (the + marker is unchanged). + +- **The factory's `inherits` slot is a snapshot.** Built once + at construction, before the closure, and used for both the + factory's metadata and every produced instance's `inherits`. + The caller's array is not mutated. Classification is + preserved across caller-side mutations on the original + list. + +## Docs + +- **Public examples in `apps/web/content/docs/*.mdx` are + type-checked AND runtime-verified against `dist/index.d.ts`.** + `tests/docs-examples-typecheck.mjs` walks every fenced + TypeScript code block in the docs, runs `tsc --noEmit` + against the published types, then executes the block under + `node --experimental-strip-types` and verifies every + `console.log(...) // -> ` annotation against the + captured stdout. A doc that announces the wrong value + fails the probe. As part of the audit, all MDX examples + were rewritten to compile, to assert the values the page + claims, and to build chains layer-by-layer so the + announced `causes(...).length` matches the walk. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5605560..0eb37cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main, staging, dev] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -126,7 +129,8 @@ jobs: # directory names contain a dot (e.g. `llms.txt/route.ts`). The # production tsconfig does not enable it, so we run type-check:test # explicitly. The regular `pnpm turbo type-check` is run on the - # rest of the workspace by the `Type Check` job below. + # rest of the workspace by the `Type Check` job below. The + # `Type Check (Tests)` job runs the same for @deessejs/errors. - name: Type-check apps/web test suite run: pnpm --filter web type-check:test @@ -167,6 +171,114 @@ jobs: - name: Run type check run: pnpm turbo type-check + # ============================================================================ + # Type Check (Tests) — exercises the test files in @deessejs/errors + # ============================================================================ + type-check-tests: + name: Type Check (Tests) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install dependencies + run: pnpm install + + # The standard `pnpm turbo type-check` runs `tsc --noEmit` against + # the source tsconfig which includes only `src/`. The + # `tsconfig.test.json` extends the base and includes the test + # files; this job catches regressions in the type-level tests + # that the source typecheck would silently miss. + - name: Run type check on tests + run: pnpm --filter @deessejs/errors type-check:test + + # ============================================================================ + # Consumer from dist — proves the published entry point is consumable + # ============================================================================ + consumer-from-dist: + name: Consumer from dist + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install dependencies + run: pnpm install + + # The script builds first (tsc -p tsconfig.build.json) and + # then runs tests/consumer-from-dist.mjs which imports + # dist/index.js and exercises the public API. + - name: Build and run consumer smoke test + run: pnpm --filter @deessejs/errors test:consumer + + # ============================================================================ + # Docs examples — public example blocks in apps/web/content/docs/*.mdx + # must compile against the published dist/index.d.ts. The probe + # walks every fenced TypeScript code block, runs tsc on each in + # isolation, and reports drift between the docs and the public API. + # ============================================================================ + docs-examples-typecheck: + name: Docs examples type-check + runs-on: ubuntu-latest + # Restrict the GITHUB_TOKEN to read-only; the job only + # checks out code and runs tsc, no write access needed. + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + # Builds first (tsc -p tsconfig.build.json), then walks + # the MDX code blocks in apps/web/content/docs/ and runs + # tsc against the published dist/index.d.ts. + - name: Build and type-check public examples + run: pnpm --filter @deessejs/errors test:docs + # ============================================================================ # Changeset required (PRs to staging only) # ============================================================================ diff --git a/.gitignore b/.gitignore index a253966..c1d6aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -43,5 +43,9 @@ temp/ # Generated files *.tsbuildinfo +# Docs examples type-check probe fixtures (generated on the +# fly by packages/errors/tests/docs-examples-typecheck.mjs). +packages/errors/tests/.docs-probe/ + # Changesets .changeset/ diff --git a/apps/web/content/docs/api-reference.mdx b/apps/web/content/docs/api-reference.mdx index 10ef065..7fa1fdf 100644 --- a/apps/web/content/docs/api-reference.mdx +++ b/apps/web/content/docs/api-reference.mdx @@ -13,27 +13,37 @@ Creates an error factory function for defining typed, structured errors. const errorFactory = error(config) ``` +There are two public overloads: + +- **No-schema overload**: `error({ name, message?, inherits? })`. The + call signature accepts no arguments (or an empty object) when `T` + is the empty shape; otherwise the input argument is required. +- **Schema overload**: `error({ name, fields, message, inherits? })`. + The factory's call signature requires the schema's `InferInput` + as the argument. A function-form `message` is required; a string + `message` is not accepted alongside a schema. + ### Parameters | Parameter | Type | Description | |-----------|------|-------------| | config.name | string | Error name identifier (required) | -| config.message | string or function | Legacy template, or a function (data) => string taking the validated fields | -| config.fields | StandardSchemaV1 | Field validation schema (Zod, Valibot, ArkType, etc.) | -| config.inherits | ErrorFactory or ErrorFactory[] | Parent error(s) to inherit from | +| config.message | string or function | String template (legacy, `{field}` placeholders) or `(data) => string` taking the validated (post-transform) fields. **Required to be a function when a schema is supplied.** | +| config.fields | StandardSchemaV1 | Field validation schema (Zod, Valibot, ArkType, etc.). The schema's output must be a non-null, non-array object — see [Fields and Schema](/docs/fields-schema). | +| config.inherits | ErrorFactory or ErrorFactory[] | Parent error(s) to inherit from. The leaf's `InferOutput` must be assignable to every parent's `InferOutput` (enforced at the call site by the contravariance witness). | -When fields is supplied, the input is validated against the schema at every call. Invalid inputs throw an ArgsValidationError. When message is a function, it receives the parsed (post-transform) data as its argument. +When `fields` is supplied, the input is validated against the schema at every call. Invalid inputs throw an `ArgsValidationError`. When `message` is a function, it receives the parsed (post-transform) data as its argument. ### Returns -An ErrorFactory function that creates error instances. The factory has these properties: +An `ErrorFactory` function that creates error instances. The factory has these properties: | Property | Type | Description | |----------|------|-------------| | name | string | The error name | | inherits | ErrorFactory or ErrorFactory[] or undefined | Parent error types | -| fields | StandardSchemaV1 or undefined | The validation schema (exposed via the factory) | -| rawMessage | string or undefined | Original message template, only set when message is a string | +| schema | StandardSchemaV1 or undefined | The validation schema (exposed via the factory) | +| rawMessage | string or function or undefined | Original message template (string) or function, set when `message` is supplied | ### Example @@ -41,10 +51,12 @@ An ErrorFactory function that creates error instances. The factory has these pro import { z } from "zod"; import { error } from "@deessejs/errors"; +const AppError = error({ name: "AppError" }); + const ValidationError = error({ name: "ValidationError", fields: z.object({ field: z.string() }), - message: (data) => 'Field "' + data.field + '" is invalid', + message: (data) => `Field "${data.field}" is invalid`, inherits: AppError, }); @@ -75,18 +87,28 @@ never - this function always throws. ### Example ```ts title="raise.ts" -import { error, raise } from "@deessejs/errors"; +import { error, raise, is } from "@deessejs/errors"; const ValidationError = error({ name: "ValidationError" }); -raise(ValidationError({})); +try { + // The no-schema factory takes no arguments. + raise(ValidationError()); +} catch (err) { + console.log(is(err, ValidationError)); // -> true +} ``` --- ## is() -Type guard function to check if an error is an instance of a specific error type. Supports single and multiple inheritance hierarchies. +Type guard function to check if an error is an instance of a specific error type. Two overloads are exposed: + +- **Factory overload**: `is(error, factory)` narrows the input to `ErrorInstance>`. The walk traverses the factory's `inherits` graph (depth-first, with cycle detection) and consults the package-private `INSTANCE_REGISTRY` to reject foreign objects that imitate the factory marker. +- **Native constructor overload**: `is(error, NativeErrorClass)` narrows the input to `InstanceType`. A native `Error` subclass carries no `.fields`, `.notes`, `.from()`, or `.addNote()` extensions. + +`is()` is **per-package-load**: an instance created by a second copy of `@deessejs/errors` (a duplicate in `node_modules`, a separate bundle, a CommonJS/ESM dual load) is registered in *that* copy's internal registry, not ours. Holding a direct reference to its factory is not sufficient — the registry check fails, and `is()` returns false. To recognize cross-load instances, the consumer must call `is()` from the same load that produced the instance. ```ts title="is.ts" const result = is(error: unknown, ErrorType): error is ErrorInstance @@ -114,7 +136,8 @@ const ValidationError = error({ inherits: AppError, }); -const err = ValidationError({}); +// The no-schema factory has an empty input shape. +const err = ValidationError(); if (is(err, ValidationError)) { console.log("ValidationError matched"); @@ -122,13 +145,22 @@ if (is(err, ValidationError)) { if (is(err, AppError)) { console.log("AppError matched via inheritance"); } + +// Native error discrimination works too. +try { + JSON.parse("invalid"); +} catch (e) { + if (is(e, SyntaxError)) { + console.log("SyntaxError caught"); + } +} ``` --- ## causes() -Returns all causes in an error chain, ordered newest to oldest. +Returns all causes in an error chain, ordered immediate cause first, root cause last. The walk follows each cause's own `.cause` link with cycle detection. ```ts title="causes.ts" const chain = causes(error: unknown): Error[] @@ -142,7 +174,7 @@ const chain = causes(error: unknown): Error[] ### Returns -Error[] - array of errors in the cause chain. Returns an empty array for null, undefined, or errors without causes. +Error[] - array of errors in the cause chain. Returns an empty array for null, undefined, or errors without causes. The returned array is a fresh copy; mutations do not affect the underlying error. ### Example @@ -150,11 +182,16 @@ Error[] - array of errors in the cause chain. Returns an empty array for null, u import { error, causes } from "@deessejs/errors"; const AppError = error({ name: "AppError" }); -const appErr = AppError({}); -appErr.from(new Error("Original error")); +const ValidationError = error({ name: "ValidationError" }); + +// Build the chain by attaching each cause to the previous one. +// The .from() call replaces the previous direct cause. +const root = new Error("Connection timeout"); +const middle = ValidationError().from(root); +const appErr = AppError().from(middle); const chain = causes(appErr); -console.log(chain.length); +console.log(chain.length); // 2 — [middle, root] ``` @@ -225,19 +262,18 @@ The type of object returned by error factories. It extends the native Error type | name | string | Error name identifier | | message | string | Human-readable error message | | stack | string | Stack trace string | -| fields | T | Validated fields | +| fields | T | Validated (post-transform) fields, or the empty record for the no-schema form | | notes | string[] | Additional notes (PEP 678) | -| cause | Error or null | Direct cause of this error | -| causes | Error[] | Full cause chain | +| cause | Error or null | Direct cause of this error. Each `.from()` call replaces the previous cause; the previous cause is reachable through its own `.cause` link. Use the top-level `causes()` function to walk the full chain. | | context | Record<string, unknown> or null | Injected context data | -| inherits | ErrorFactory or ErrorFactory[] or undefined | Parent error factories | +| inherits | ErrorFactory or ErrorFactory[] or undefined | Parent error factories (frozen snapshot taken at construction) | ### Methods | Method | Description | |--------|-------------| -| from(cause: Error or ErrorInstance) | Chains a cause error to this error. Returns this for chaining. | -| addNote(note: string) | Appends a runtime context note (PEP 678). Returns this for chaining. | +| from(cause: Error or ErrorInstance) | Chains a cause error to this error. Replaces the previous direct cause. Returns `this` for chaining. | +| addNote(note: string) | Appends a runtime context note (PEP 678). Returns `this` for chaining. | --- @@ -257,3 +293,95 @@ The type of object returned by error factories. It extends the native Error type Field validation with Standard Schema. + +--- + +## Constraints + +The package's contracts depend on a few consumer-side guarantees. Each is enforced at the boundary that owns it; understanding them helps avoid footguns. + +### TypeScript + +**`strictFunctionTypes` is required.** The contravariance witness +that enforces inheritance compatibility relies on function +parameters being checked contravariantly. Without +`strictFunctionTypes`, the inheritance constraint is bypassed at +compile time. `strictFunctionTypes` is enabled by `strict`, which +is on in this project's tsconfig. + +**TypeScript 5.4 or later.** The public `error()` overloads use +`NoInfer`, which was introduced in TypeScript 5.4. Earlier +versions compile but lose the `NoInfer` semantics on the `T` +parameter. + +### Schemas + +**Output must be a non-null, non-array object.** A schema whose +`InferOutput` is `null`, an `unknown[]`, a primitive, or +`undefined` is rejected at compile time via the +`fields: S & (IsObjectOutput> extends true ? unknown : never)` +intersection, and at runtime via the `isObjectFields` guard that +throws `ArgsValidationError` if the schema's value is malformed. + +**Schemas must be synchronous.** A schema whose `validate` +returns a `Promise` is rejected with `ArgsValidationError`. The +Promise is still attached and a no-op `.catch` is added so a +late rejection cannot surface as an `unhandledRejection`. + +**Input is required when a schema is supplied.** A factory +declared with a Standard Schema refuses a no-arg call at compile +time. The runtime also throws a localized `TypeError` for the +schema-bearing path so consumers who bypass the type-checker +still get a clear message. This applies even when the schema +accepts the empty shape (`z.object({})`). + +**`.fields` carries the post-transform output.** For schemas +that transform (e.g. `z.coerce`, `z.default`, `z.transform`), +the leaf's `.fields` is the schema's `InferOutput`, not the +`InferInput`. The factory's `message` function receives the +post-transform output. + +### `is()` and the instance registry + +**`is()` is per-package-load.** An instance created by a second +copy of `@deessejs/errors` (a duplicate in `node_modules`, a +separate bundle, a CommonJS/ESM dual load, two copies in a +micro-frontend) is registered in *that* copy's internal +`INSTANCE_REGISTRY` (a `WeakSet`), not ours. Holding a direct +reference to its factory is not sufficient: the marker slot is +read and matches, but the registry check fails, and `is()` +returns false. To recognize cross-load instances, the consumer +must call `is()` from the same load that produced the instance. + +**The instance marker is non-writable on real instances.** Set +via `Object.defineProperty` with `writable: false`. A foreign +object that imitates the marker is rejected by the registry +check; a hostile object that re-defines the marker on a real +instance is rejected in strict mode (the assignment throws) and +silently fails in sloppy mode (the marker is unchanged). + +### Inheritance + +**The factory's `inherits` slot is a frozen snapshot.** Built +once at construction, before the closure, and used for both the +factory's metadata and every produced instance's `inherits`. +The caller's array is **not** mutated in place; classification +is preserved across caller-side mutations on the original list. + +**Each factory runs its own schema.** Inheritance is a static +type-level relationship plus a recognition walk for `is()`. The +parent schema does **not** validate the child's data at +instantiation. Consumers who relied on the parent-schema cascade +to coerce a child field through a parent's transform must +compose the schema themselves. + +### Legacy deprecation + +The string-template form (`error({ name, message: 'Hello +{name}' })`) is deprecated in 2.0.0 and emits a one-time warning +per call site, only when the `message` is a string with +`{field}` placeholders, and only when +`process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES` is not `'1'`. A +plain string without placeholders and a function-form `message` +do not warn. The warning is suppressed in browser bundles +(`process` is read only when defined). diff --git a/apps/web/content/docs/causes-function.mdx b/apps/web/content/docs/causes-function.mdx index 19f479c..662170d 100644 --- a/apps/web/content/docs/causes-function.mdx +++ b/apps/web/content/docs/causes-function.mdx @@ -3,30 +3,33 @@ title: The causes() Function description: Use the causes() function to traverse error chains and access all causes in an error. --- -The `causes()` function provides a safe way to access the cause chain of any error. It works with both @deessejs/errors instances and native JavaScript errors, returning an empty array for errors without causes. +The `causes()` function walks the cause chain of an error, returning every link in order — the **immediate cause first**, the **root cause last**. It accepts `@deessejs/errors` instances, native `Error` instances, and `null`/`undefined` (returns `[]` for those). The walk detects cycles and returns a fresh array on each call. -## Basic Usage +A single error has one **direct cause** at a time: each `.from()` call replaces the previous one. To build a chain of length N, the previous cause itself must carry its own `.cause`. Calling `.from()` repeatedly on the same error does **not** accumulate causes — the second call overwrites the first. -Call `causes()` with an error to get its cause chain as an array: +## Building a Chain -```ts title="basic.ts" +Build the chain by attaching each cause to the previous one. The leaf at the top carries its own cause; the cause carries the next layer's cause, and so on. + +```ts title="chain.ts" import { error, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Network timeout')); +// The no-schema factories have an empty input shape; the +// call takes no arguments. +const root = new Error('Network timeout'); +const validationErr = ValidationError().from(root); +const appErr = AppError().from(validationErr); const chain = causes(appErr); - -console.log(chain.length); // 2 -console.log(chain[0].name); // "ValidationError" -console.log(chain[1].message); // "Network timeout" +console.log(chain.length); // -> 2 +console.log(chain[0].name); // -> ValidationError +console.log(chain[1].message); // -> Network timeout ``` -The returned array contains only the errors that were explicitly added using `from()`, ordered from newest to oldest. +The chain is `[validationErr, root]` — immediate cause first, root cause last. `causes()` walks each link's own `.cause`, so the chain is recoverable even though only the topmost error has its `.cause` slot set directly. ## Why Use causes() Instead of Direct Access? @@ -38,14 +41,14 @@ import { causes } from '@deessejs/errors'; // Works with native errors (no causes property) const nativeError = new Error('Something failed'); const chain1 = causes(nativeError); -console.log(chain1.length); // 0 +console.log(chain1.length); // -> 0 // Works with null/undefined (returns empty array) const chain2 = causes(null); -console.log(chain2.length); // 0 +console.log(chain2.length); // -> 0 const chain3 = causes(undefined); -console.log(chain3.length); // 0 +console.log(chain3.length); // -> 0 ``` This robustness makes `causes()` ideal for writing generic error handling code that doesn't need to check the error type first. @@ -60,21 +63,19 @@ import { error, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Database connection failed')); +// Build the chain: ValidationError -> DatabaseError -> AppError. +const databaseErr = new Error('Database connection failed'); +const validationErr = ValidationError().from(databaseErr); +const appErr = AppError().from(validationErr); -// Log each cause for (const cause of causes(appErr)) { console.log(`[${cause.name}] ${cause.message}`); } - -// Output: -// [ValidationError] ValidationError -// [Error] Database connection failed +// -> [ValidationError] ValidationError +// -> [Error] Database connection failed ``` -This is useful for building detailed error logs or monitoring dashboards that track where errors originate. +The walk is order-preserving: the immediate cause is yielded first. ## Building Error Summaries @@ -86,21 +87,22 @@ import { error, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('DB connection failed')); +// Build the chain: ValidationError -> DatabaseError -> AppError. +const databaseErr = new Error('DB connection failed'); +const validationErr = ValidationError().from(databaseErr); +const appErr = AppError().from(validationErr); const summary = causes(appErr) .map((cause) => `${cause.name}: ${cause.message}`) - .join(' → '); + .join(' -> '); console.log(summary); -// "ValidationError: ValidationError → Error: DB connection failed" +// -> ValidationError: ValidationError -> Error: DB connection failed ``` ## Combining with is() -Use `causes()` with `is()` to search for specific error types in the chain: +Use `causes()` with `is()` to search for specific error types in the chain. Each layer of the chain carries its own type, so the walk checks every link. ```ts title="combined.ts" import { error, causes, is } from '@deessejs/errors'; @@ -109,19 +111,47 @@ const AppError = error({ name: 'AppError' }); const NetworkError = error({ name: 'NetworkError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(NetworkError({ endpoint: '/api/users' })); -appErr.from(ValidationError({ field: 'email' })); +// The chain is NetworkError -> ValidationError -> AppError. +const validationErr = ValidationError(); +const networkErr = NetworkError().from(validationErr); +const appErr = AppError().from(networkErr); -// Check if any cause is a NetworkError +// The walk finds the NetworkError at chain[0] and the +// ValidationError at chain[1] (the root cause, last). const hasNetworkFailure = causes(appErr).some( - (cause) => is(cause, NetworkError) + (cause) => is(cause, NetworkError), ); +console.log(hasNetworkFailure); // -> true -console.log(hasNetworkFailure); // true +const hasValidationError = causes(appErr).some( + (cause) => is(cause, ValidationError), +); +console.log(hasValidationError); // -> true ``` -This pattern is useful when you need to know not just that an error happened, but what kind of errors were involved in its chain. +If you replace the direct cause of `appErr` with something that is not a `NetworkError`, the search returns `false` — `causes()` follows the chain, not the replacement history: + +```ts title="replacement.ts" +import { error, causes, is } from '@deessejs/errors'; + +const AppError = error({ name: 'AppError' }); +const NetworkError = error({ name: 'NetworkError' }); +const ValidationError = error({ name: 'ValidationError' }); + +// First link: NetworkError -> ValidationError -> AppError. +const validationErr = ValidationError(); +const networkErr = NetworkError().from(validationErr); +const appErr = AppError().from(networkErr); + +// Replacing the direct cause drops the NetworkError from +// the walk. The previous cause is no longer reachable +// through .cause. +appErr.from(new Error('Disk full')); + +console.log( + causes(appErr).some((cause) => is(cause, NetworkError)), +); // -> false +``` ## See Also @@ -132,4 +162,4 @@ This pattern is useful when you need to know not just that an error happened, bu Use is() to check error types. - \ No newline at end of file + diff --git a/apps/web/content/docs/error-factory.mdx b/apps/web/content/docs/error-factory.mdx index db6e253..658673b 100644 --- a/apps/web/content/docs/error-factory.mdx +++ b/apps/web/content/docs/error-factory.mdx @@ -16,16 +16,17 @@ const NotFoundError = error({ name: "NotFoundError", }); -const err = NotFoundError({}); -console.log(err.name); // "NotFoundError" -console.log(err.message); // "NotFoundError" +// The no-schema factory takes no arguments. +const err = NotFoundError(); +console.log(err.name); // -> NotFoundError +console.log(err.message); // -> NotFoundError ``` When you provide only a name, the message defaults to the name. This is useful for simple errors where the name itself is descriptive enough. ## Custom Messages -You can provide a custom message function that takes the validated fields and returns the final string: +A `message` function takes the validated fields and returns the final string. The function form is the only path supported alongside a schema; the no-schema form also accepts a function so the page presents a single shape. ```ts title="custom-message.ts" import { error } from "@deessejs/errors"; @@ -35,24 +36,27 @@ const ValidationError = error({ message: () => "Validation failed", }); -const err = ValidationError({}); -console.log(err.message); // "Validation failed" +const err = ValidationError(); +console.log(err.message); // -> Validation failed ``` -The function form is recommended. The legacy string-template form (with `{placeholder}` substitution) still works but is deprecated and will be removed in the next major. See [Message Rendering](/docs/message-templates) for details. - ## Creating Instances with Fields -The real power of error factories comes from attaching structured data. You can declare the fields type using a generic parameter: +The real power of error factories comes from attaching structured data. With a Standard Schema, the leaf's `fields` are typed by `InferOutput` and validated at every call. ```ts title="with-fields.ts" -const UserError = error<{ userId: string; reason: string }>({ +import { error } from "@deessejs/errors"; +import { z } from "zod"; + +const UserError = error({ name: "UserError", + fields: z.object({ userId: z.string(), reason: z.string() }), + message: (data) => `User ${data.userId}: ${data.reason}`, }); const err = UserError({ userId: "usr_123", reason: "not found" }); -console.log(err.fields.userId); // "usr_123" -console.log(err.fields.reason); // "not found" +console.log(err.fields.userId); // -> usr_123 +console.log(err.fields.reason); // -> not found ``` The fields object is fully accessible on the instance — useful for logging, error monitoring, and tooling. @@ -75,7 +79,7 @@ const ValidationError = error({ }); const err = ValidationError({ field: "email", reason: "invalid format" }); -console.log(err.message); // 'Field "email" is invalid: invalid format' +console.log(err.message); // -> Field "email" is invalid: invalid format ``` See [Fields and Schema](/docs/fields-schema) for the full breakdown, including vendor-neutral coverage of Zod, Valibot, and ArkType. @@ -85,13 +89,15 @@ See [Fields and Schema](/docs/fields-schema) for the full breakdown, including v The function returned by `error()` has several useful properties attached to it: ```ts title="properties.ts" +import { error } from "@deessejs/errors"; + const AppError = error({ name: "AppError" }); -console.log(AppError.name); // "AppError" -console.log(AppError.inherits); // undefined (no parent) +console.log(AppError.name); // -> AppError +console.log(AppError.inherits); // -> undefined ``` -The `inherits` property exposes the parent factory or array of factories, which `is()` consults when checking inheritance. The schema is reachable via `AppError.fields` when supplied. +The `inherits` property exposes the parent factory or array of factories, which `is()` consults when checking inheritance. The instance carries the validated `fields` shape and the parent relationships for introspection. ## Reusing Error Factories @@ -99,19 +105,20 @@ Error factories are designed to be created once and reused throughout your appli ```ts title="definitions.ts" import { error } from "@deessejs/errors"; +import { z } from "zod"; -export const NotFoundError = error({ +const NotFoundError = error({ name: "NotFoundError", message: () => "Resource not found", }); -export const ValidationError = error({ +const ValidationError = error({ name: "ValidationError", fields: z.object({ field: z.string(), reason: z.string() }), message: (data) => `Field ${data.field}: ${data.reason}`, }); -export const NetworkError = error({ +const NetworkError = error({ name: "NetworkError", message: () => "Network request failed", }); @@ -120,11 +127,16 @@ export const NetworkError = error({ Then import and use them wherever needed: ```ts title="usage.ts" -import { raise } from "@deessejs/errors"; -import { NotFoundError } from "./errors"; +import { error, raise, is } from "@deessejs/errors"; + +const NotFoundError = error({ name: "NotFoundError" }); -if (!resource) { - raise(NotFoundError({})); +try { + raise(NotFoundError()); +} catch (err) { + if (is(err, NotFoundError)) { + console.log(err.name); // -> NotFoundError + } } ``` diff --git a/apps/web/content/docs/error-instance.mdx b/apps/web/content/docs/error-instance.mdx index a201866..2b4d4e9 100644 --- a/apps/web/content/docs/error-instance.mdx +++ b/apps/web/content/docs/error-instance.mdx @@ -12,128 +12,143 @@ Error instances include all standard `Error` properties: ```ts title="properties.ts" import { error } from '@deessejs/errors'; -const AppError = error({ name: 'AppError', message: 'Something went wrong' }); -const err = AppError({}); +const AppError = error({ name: 'AppError' }); +const err = AppError(); -console.log(err.name); // "AppError" -console.log(err.message); // "Something went wrong" -console.log(err.stack); // Stack trace string -console.log(err instanceof Error); // true +console.log(err.name); // -> AppError +console.log(err.message); // -> AppError +console.log(typeof err.stack); // -> string +console.log(err instanceof Error); // -> true ``` These properties behave exactly like native errors, so they work with all existing error handling code and tools. ## The fields Property -The `fields` property contains the data you passed when creating the error. This is where you store structured context about what went wrong. +The `fields` property carries the data the schema validated when the error was constructed. This is where you store structured context about what went wrong. A no-schema factory's `fields` is the empty object `{}`. ```ts title="fields.ts" -const ValidationError = error<{ field: string; value: unknown }>({ +import { z } from 'zod'; +import { error } from '@deessejs/errors'; + +const ValidationError = error({ name: 'ValidationError', - message: 'Invalid value', + fields: z.object({ field: z.string(), value: z.unknown() }), + message: () => 'Invalid value', }); const err = ValidationError({ field: 'email', value: 'not-an-email' }); -console.log(err.fields.field); // "email" -console.log(err.fields.value); // "not-an-email" +console.log(err.fields.field); // -> email +console.log(err.fields.value); // -> not-an-email ``` -The fields object is typed based on what you defined in the error factory, providing full TypeScript support for accessing these values. +The fields object is typed by the schema's `InferOutput`, so TypeScript checks every property access. ## The from() Method The `from()` method chains another error as the cause of the current error. This is how you preserve the full context of what led to an error. ```ts title="from-method.ts" -import { error } from '@deessejs/errors'; +import { error, is, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const validationErr = ValidationError({ field: 'email' }); -const appErr = AppError({}); +const validationErr = ValidationError(); +const appErr = AppError(); -// Chain the validation error as a cause +// Chain the validation error as a cause. appErr.from(validationErr); -console.log(appErr.cause === validationErr); // true -console.log(appErr.causes.length); // 1 +console.log(appErr.cause === validationErr); // -> true +// The full chain is reachable via the causes() helper, which +// walks the cause links and returns the chain immediate-cause first. +console.log(causes(appErr).length); // -> 1 +// `is()` walks the inheritance graph; the type guard narrows +// `validationErr` to the same factory. +console.log(is(validationErr, ValidationError)); // -> true ``` The `from()` method returns the error instance, allowing you to chain multiple calls or combine it with other operations. ## The cause Property -The `cause` property holds the direct cause of the error — the error passed to the most recent `from()` call. +The `cause` property holds the direct cause of the error — the error passed to the most recent `from()` call. Use `is()` to narrow it to a specific factory; the built-in `instanceof` checks against the native `Error`, not the factory marker. ```ts title="cause.ts" +import { error, is } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'name' })); +const appErr = AppError(); +appErr.from(ValidationError()); -console.log(appErr.cause instanceof ValidationError); // true +console.log(appErr.cause === null); // -> false +console.log(is(appErr.cause, ValidationError)); // -> true ``` When no cause has been set, `cause` is `null`. -## The causes Array +## The causes() helper -The `causes` array contains the entire chain of errors, ordered from most recent to oldest. This gives you the full history of what happened. +The `causes()` function returns the full chain of errors, ordered immediate cause first, root cause last. It walks the `.cause` link of each error in the chain, detects cycles, and accepts native `Error` and `null`/`undefined` (returns `[]` for those). It is a top-level function, not a property on the instance. ```ts title="causes-array.ts" +import { error, causes } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Database connection failed')); +// Build the chain: ValidationError -> DatabaseError -> AppError. +const databaseErr = new Error('Database connection failed'); +const validationErr = ValidationError().from(databaseErr); +const appErr = AppError().from(validationErr); -console.log(appErr.causes.length); // 2 -console.log(appErr.causes[0].name); // "ValidationError" (most recent) -console.log(appErr.causes[1].message); // "Database connection failed" (oldest) +// Walk the chain via causes() — immediate cause first, root cause last. +const chain = causes(appErr); +console.log(chain.length); // -> 2 +console.log(chain[0].name); // -> ValidationError +console.log(chain[1].message); // -> Database connection failed ``` -When you chain multiple errors, the new cause is added to the front of the array, keeping chronological order. +When you chain multiple errors, the new cause replaces the direct cause; the previous cause becomes the second link in the chain, keeping causal order. ## The notes Property -The `notes` property allows you to attach additional context to an error after it's created. This is useful for adding debugging information in catch blocks. +The `notes` property lets you attach additional context to an error after it is created. Each `addNote()` call returns `this` for chaining, and the runtime maintains the `notes` array in the order the calls were made. ```ts title="notes.ts" +import { error } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); -const err = AppError({}); +const err = AppError(); // Add context notes -err.notes.push('Caught in user controller'); -err.notes.push('User ID was usr_123'); +err.addNote('Caught in user controller'); +err.addNote('User ID was usr_123'); -console.log(err.notes); -// ["Caught in user controller", "User ID was usr_123"] +console.log(err.notes[0]); // -> Caught in user controller +console.log(err.notes[1]); // -> User ID was usr_123 ``` -Notes are stored as an array of strings and can be modified at any time. +Notes are stored as a readonly array of strings. To add notes, use `addNote()` rather than `notes.push()`; the `notes` slot is a snapshot and is replaced on each `addNote()` call. ## The context Property -The `context` property holds additional data that was injected into the error. This is useful for framework integrations or middleware that need to attach extra information. +The `context` property holds additional data injected into the error. It is `null` by default and is meant to be set at construction time when an error needs to carry auxiliary data the schema doesn't cover. ```ts title="context.ts" -const AppError = error({ name: 'AppError' }); -const err = AppError({}); +import { error } from '@deessejs/errors'; -// Attach context data -err.context = { - requestId: 'req_abc123', - timestamp: new Date().toISOString(), -}; +const AppError = error({ name: 'AppError' }); +const err = AppError(); -console.log(err.context.requestId); // "req_abc123" +console.log(err.context); // -> null ``` -When no context has been set, `context` is `null`. +When no context has been set, `context` is `null`. To inject structured context, prefer carrying it through your factory's own schema and the `fields` slot — that keeps the data flow type-checked end to end. ## See Also @@ -147,4 +162,4 @@ When no context has been set, `context` is `null`. Check error types using the is() function. - \ No newline at end of file + diff --git a/apps/web/content/docs/fields-schema.mdx b/apps/web/content/docs/fields-schema.mdx index 1d67321..629698f 100644 --- a/apps/web/content/docs/fields-schema.mdx +++ b/apps/web/content/docs/fields-schema.mdx @@ -7,34 +7,34 @@ Errors become much more useful when they carry structured data about what went w ## Defining Fields -When you create an error factory, you can specify a generic type that defines the shape of the fields object: +With a Standard Schema, the field shape is the schema's `InferOutput`. The factory's call signature requires the schema's `InferInput`. Inputs that don't validate throw `ArgsValidationError` at construction. ```ts title="basic.ts" +import { z } from "zod"; import { error } from "@deessejs/errors"; -const ValidationError = error<{ - field: string; - reason: string; - value?: unknown; -}>({ +const ValidationError = error({ name: "ValidationError", - message: "Validation failed", + fields: z.object({ + field: z.string(), + reason: z.string(), + value: z.unknown().optional(), + }), + message: (data) => `Field "${data.field}": ${data.reason}`, }); -``` -Now when you create an error instance, TypeScript ensures you provide the required fields: - -```ts title="usage.ts" const err = ValidationError({ field: "email", reason: "invalid format", }); +console.log(err.fields.field); // -> email const err2 = ValidationError({ field: "email", reason: "invalid format", value: "not-an-email", }); +console.log(err2.fields.value); // -> not-an-email ``` The fields object is always accessible on the error instance, giving you a consistent place to look for error context. @@ -44,24 +44,31 @@ The fields object is always accessible on the error instance, giving you a consi Once an error is caught, you can access its fields to provide meaningful feedback or logging: ```ts title="access.ts" -import { error, raise } from "@deessejs/errors"; +import { z } from "zod"; +import { error, is, raise } from "@deessejs/errors"; -const ValidationError = error<{ field: string; reason: string }>({ +const ValidationError = error({ name: "ValidationError", - message: "Validation failed", + fields: z.object({ field: z.string(), reason: z.string() }), + message: (data) => `Field ${data.field}: ${data.reason}`, }); try { - raise(ValidationError({ field: "email", reason: "not a valid email address" })); -} catch (err) { - // TypeScript knows the shape of fields once you narrow via is() - if (is(err, ValidationError)) { - console.log(`Error in field "${err.fields.field}": ${err.fields.reason}`); + try { + raise(ValidationError({ field: "email", reason: "not a valid email address" })); + } catch (err) { + // err is `unknown` in strict mode; narrow before reading. + if (is(err, ValidationError)) { + console.log(`Error in field "${err.fields.field}": ${err.fields.reason}`); + } } +} catch (err) { + // The outer try swallows the throw so the example + // terminates cleanly. } ``` -For full type inference in catch blocks, use the [`is()`](/docs/type-checking) function with type narrowing. +The expected output is `Error in field "email": not a valid email address`. For full type inference in catch blocks, use the [`is()`](/docs/type-checking) function with type narrowing. ## Runtime Validation via Standard Schema @@ -84,15 +91,15 @@ const ValidationError = error({ // Valid input -> rendered message const ok = ValidationError({ field: "email", reason: "invalid" }); -console.log(ok.message); // 'Field "email": invalid' +console.log(ok.message); // -> Field "email": invalid // Invalid input -> ArgsValidationError try { ValidationError({ field: "", reason: "invalid" }); } catch (err) { if (err instanceof ArgsValidationError) { - console.log(err.vendor); // "zod" - console.log(err.issues); // StandardSchemaV1.FailureResult['issues'] + console.log(err.vendor); // -> zod + console.log(err.issues.length > 0); // -> true } } ``` @@ -111,6 +118,9 @@ const ValidationError = error({ }), message: (data) => `Field ${data.field}: ${data.reason}`, }); + +const err = ValidationError({ field: "email", reason: "invalid" }); +console.log(err.message); // -> Field email: invalid ``` ### Using ArkType @@ -127,9 +137,16 @@ const ValidationError = error({ }), message: (data) => `Field ${data.field}: ${data.reason}`, }); + +const err = ValidationError({ field: "email", reason: "invalid" }); +console.log(err.message); // -> Field email: invalid ``` -> If you don't pass a `message` function, the schema's **output** type is still validated at construction time, but `err.message` will be a JSON dump of the parsed data. +> The schema overload requires a function-form `message`. The signature +> `error({ fields, message: 'literal' })` is a TypeScript error: a string +> `message` is no longer accepted alongside a schema. The function +> receives the schema's post-transform output. The leaf's `.fields` +> slot carries the same output. ## Why Use Schema Validation? @@ -148,24 +165,39 @@ Schema validation in error factories provides several benefits: Here are some common patterns for error fields: ```ts title="patterns.ts" -const DatabaseError = error<{ - query: string; - table?: string; - code?: string; -}>({ name: "DatabaseError" }); - -const ApiError = error<{ - endpoint: string; - statusCode: number; - response?: unknown; -}>({ name: "ApiError" }); - -const ValidationError = error<{ - field: string; - reason: string; - value?: unknown; - constraints?: Record; -}>({ name: "ValidationError" }); +import { z } from "zod"; +import { error } from "@deessejs/errors"; + +const DatabaseError = error({ + name: "DatabaseError", + fields: z.object({ + query: z.string(), + table: z.string().optional(), + code: z.string().optional(), + }), + message: (data) => `${data.query} failed`, +}); + +const ApiError = error({ + name: "ApiError", + fields: z.object({ + endpoint: z.string(), + statusCode: z.number(), + response: z.unknown().optional(), + }), + message: (data) => `Request to ${data.endpoint} returned ${data.statusCode}`, +}); + +const ValidationError = error({ + name: "ValidationError", + fields: z.object({ + field: z.string(), + reason: z.string(), + value: z.unknown().optional(), + constraints: z.string().optional(), + }), + message: (data) => `${data.field}: ${data.reason}`, +}); ``` Design your fields to capture what's useful for debugging and logging, not just what's required to identify the error. diff --git a/apps/web/content/docs/from-method.mdx b/apps/web/content/docs/from-method.mdx index e2128ac..4b26e37 100644 --- a/apps/web/content/docs/from-method.mdx +++ b/apps/web/content/docs/from-method.mdx @@ -15,11 +15,12 @@ import { error } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -// Create the original error -const validationErr = ValidationError({ field: 'email' }); +// Create the original error. The no-schema form has an empty +// input shape, so the call is `ValidationError()` with no args. +const validationErr = ValidationError(); // Create the wrapping error -const appErr = AppError({}); +const appErr = AppError(); // Chain the validation error as the cause appErr.from(validationErr); @@ -31,63 +32,97 @@ The `from()` method returns the error instance, so you can chain method calls or ## Multiple Causes -A single error can have multiple causes in its chain. Each call to `from()` adds a new cause to the front of the chain: +A single error has one **direct cause** at a time: each `from()` call replaces the previous cause with a new one. To build a chain of length N, the previous cause itself must carry its own `.cause`. Calling `.from()` repeatedly on the same error does **not** accumulate a list — the second call overwrites the first. + +Build the chain by attaching each cause to the previous one. The `causes()` helper then walks the chain by following each cause's own `.cause` link, returning the immediate cause down to the root: ```ts title="multiple.ts" +import { error, causes } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); - -// Add causes in order -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Database connection failed')); +// Build the chain: the root cause is attached first, then +// the next layer, then the leaf. Each .from() sets the +// direct cause on the receiver. +const databaseErr = new Error('Database connection failed'); +const validationErr = ValidationError().from(databaseErr); +const appErr = AppError().from(validationErr); -// Most recent cause is first -console.log(appErr.causes.length); // 2 -console.log(appErr.causes[0].name); // "ValidationError" -console.log(appErr.causes[1].message); // "Database connection failed" +// Walk the chain — the order is immediate cause first, root +// cause last. causes() detects cycles. +const chain = causes(appErr); +console.log(chain.length); // 2 — [validationErr, databaseErr] +console.log(chain[0].name); // "ValidationError" +console.log(chain[1].message); // "Database connection failed" ``` -The order is always newest first, which makes sense when reading error chains — the most recent cause is what led directly to the current error. +The chain order is always immediate-cause first, root-cause last. ## Chaining with raise() You can combine `from()` with `raise()` for concise error handling: ```ts title="with-raise.ts" -import { error, raise } from '@deessejs/errors'; +import { error, raise, is } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); try { - // Something throws a validation error - throw ValidationError({ field: 'email' }); + try { + // Something throws a validation error. + raise(ValidationError()); + } catch (err) { + // err is `unknown` in strict mode; check before .from(err). + if (err instanceof Error) { + raise(AppError().from(err)); + } else { + throw err; + } + } } catch (err) { - // Wrap it in an application error - raise(AppError({}).from(err)); + // The outer try swallows the chained throw so the + // example terminates cleanly. In real code the raise + // propagates to the caller. + if (is(err, AppError)) { + console.log(err.cause !== null); // -> true + console.log(is(err.cause, ValidationError)); // -> true + } } ``` Since `from()` returns the error instance, you can write this more compactly: ```ts title="compact.ts" -import { error, raise } from '@deessejs/errors'; +import { error, raise, is } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); try { - throw ValidationError({ field: 'email' }); + try { + raise(ValidationError()); + } catch (err) { + if (err instanceof Error) { + raise(AppError().from(err)); + } else { + throw err; + } + } } catch (err) { - raise(AppError({}).from(err)); + if (is(err, AppError)) { + console.log(err.cause !== null); // -> true + console.log(is(err.cause, ValidationError)); // -> true + } } ``` +The `err instanceof Error` check is required in strict mode: TypeScript types a `catch` clause's binding as `unknown`, and `.from()` expects an `Error | ErrorInstance`. Without the check, the call is a type error. + ## Accessing the Full Chain -After building an error chain, you can access all causes through the `causes` array or use the `causes()` helper function for more robust access: +The `causes()` helper walks the chain by following each cause's own `.cause` link. It detects cycles and returns a new array on each call (mutations do not affect the underlying error). ```ts title="access.ts" import { error, causes } from '@deessejs/errors'; @@ -95,18 +130,20 @@ import { error, causes } from '@deessejs/errors'; const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); -const appErr = AppError({}); -appErr.from(ValidationError({ field: 'email' })); -appErr.from(new Error('Connection timeout')); +// Build the chain by attaching each cause to the previous. +const root = new Error('Connection timeout'); +const middle = ValidationError().from(root); +const appErr = AppError().from(middle); -// Using the causes array directly -console.log(appErr.causes.length); // 2 - -// Using the causes() helper (handles non-@deessejs/errors errors) +// Walk the chain via the causes() helper. const chain = causes(appErr); -console.log(chain.length); // 2 +console.log(chain.length); // 2 — [middle (ValidationError), root (Error)] +console.log(chain[0].name); // "ValidationError" +console.log(chain[1].message); // "Connection timeout" ``` +`causes()` accepts any value: a factory-produced `ErrorInstance`, a native `Error`, or `null`/`undefined` (returns `[]`). It also walks native `Error.cause` chains, so mixed native and factory errors compose. + The `causes()` function is particularly useful when working with errors from different sources, as it gracefully handles cases where the `causes` array might not exist. ## Why Chain Errors? diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx index 33ff3f4..b0c1e5f 100644 --- a/apps/web/content/docs/index.mdx +++ b/apps/web/content/docs/index.mdx @@ -17,26 +17,42 @@ The quickest way to understand how @deessejs/errors works is to see it in action ```ts title="index.ts" import { error, raise } from '@deessejs/errors'; +import { z } from 'zod'; -// Define your error types +// Define your error type. With a Standard Schema attached, +// every call validates the input and produces typed `fields`. const ValidationError = error({ name: 'ValidationError', - message: 'Validation failed', + fields: z.object({ field: z.string() }), + message: (d) => `Validation failed for ${d.field}`, }); -// Use the factory to create error instances -const validationErr = ValidationError({}); -console.log(validationErr.message); // "Validation failed" +// Use the factory to create error instances. +const validationErr = ValidationError({ field: 'email' }); +console.log(validationErr.message); // "Validation failed for email" +console.log(validationErr.fields.field); // "email" ``` For catching and handling errors, you can use the standard try/catch syntax or the library's `raise()` function: ```ts title="handling.ts" +import { error, raise } from '@deessejs/errors'; +import { z } from 'zod'; + +const ValidationError = error({ + name: 'ValidationError', + fields: z.object({ field: z.string() }), + message: (d) => `Validation failed for ${d.field}`, +}); + try { - raise(ValidationError({})); + raise(ValidationError({ field: 'email' })); } catch (err) { - console.log(err.name); // "ValidationError" - console.log(err instanceof Error); // true + // `err` is `unknown` in strict mode; narrow before reading. + if (err instanceof Error) { + console.log(err.name); // "ValidationError" + console.log(err instanceof Error); // true + } } ``` @@ -47,14 +63,26 @@ try { When an error occurs as a result of another error, you can preserve that relationship using the `.from()` method. This creates a chain that maintains the full history of what went wrong. ```ts title="chaining.ts" -const validationErr = ValidationError({}); -const appErr = error({ name: 'AppError' })(); +import { causes, error } from '@deessejs/errors'; +import { z } from 'zod'; + +const ValidationError = error({ + name: 'ValidationError', + fields: z.object({ field: z.string() }), + message: (d) => `Validation failed for ${d.field}`, +}); -// Chain the validation error as the cause +const AppError = error({ name: 'AppError' }); + +const validationErr = ValidationError({ field: 'email' }); +const appErr = AppError(); + +// Chain the validation error as the cause. appErr.from(validationErr); console.log(appErr.cause === validationErr); // true -console.log(appErr.causes.length); // 1 +// The full chain is reachable via the causes() helper. +console.log(causes(appErr).length); // 1 ``` ### Hierarchical Inheritance @@ -70,25 +98,34 @@ const ValidationError = error({ inherits: AppError, }); -const err = ValidationError({}); +// The no-schema factories have an empty input shape; the +// call takes no arguments. +const err = ValidationError(); -// Check the error type +// Check the error type. console.log(is(err, ValidationError)); // true console.log(is(err, AppError)); // true (through inheritance) ``` ### Message Templates -You can define errors with message templates that include field placeholders. These are replaced at runtime with the data you provide. +You can define errors with function-form `message`s that read the schema's post-transform output. The `message` function receives the validated fields, so the rendered string is type-checked against the schema. ```ts title="templates.ts" -const UserError = error<{ userId: string; reason: string }>({ +import { error } from '@deessejs/errors'; +import { z } from 'zod'; + +const UserError = error({ name: 'UserError', - message: 'User "{userId}" failed: {reason}', + fields: z.object({ + userId: z.string(), + reason: z.string(), + }), + message: (d) => `User "${d.userId}" failed: ${d.reason}`, }); const err = UserError({ userId: 'usr_123', reason: 'not found' }); -console.log(err.message); // "User "usr_123" failed: not found" +console.log(err.message); // 'User "usr_123" failed: not found' ``` ## See Also @@ -98,9 +135,9 @@ console.log(err.message); // "User "usr_123" failed: not found" Learn how to install and set up the library in your project. - Create custom error types with the error() function. + Create error types with the error() function. Chain errors together to preserve context. - \ No newline at end of file + diff --git a/apps/web/content/docs/message-templates.mdx b/apps/web/content/docs/message-templates.mdx index fe540b3..988d04c 100644 --- a/apps/web/content/docs/message-templates.mdx +++ b/apps/web/content/docs/message-templates.mdx @@ -23,7 +23,7 @@ const ValidationError = error({ }); const err = ValidationError({ field: "email", reason: "invalid format" }); -console.log(err.message); // 'Field "email" is invalid: invalid format' +console.log(err.message); // -> Field "email" is invalid: invalid format ``` The function receives the **Standard Schema output** type. After a `z.coerce.number()`, `z.brand()`, or any transform, you see the final shape. @@ -44,7 +44,7 @@ const FormatError = error({ }); const err = FormatError({ expected: "number", actual: "string" }); -console.log(err.message); // "Expected number, got string" +console.log(err.message); // -> Expected number, got string ``` ### With ArkType @@ -62,6 +62,9 @@ const ApiError = error({ }), message: (data) => `${data.method.toUpperCase()} ${data.endpoint} failed with status ${data.statusCode}`, }); + +const err = ApiError({ method: "post", endpoint: "/users", statusCode: 500 }); +console.log(err.message); // -> POST /users failed with status 500 ``` ### Modifiers via the function body @@ -69,42 +72,56 @@ const ApiError = error({ The function form replaces the legacy `:upper` / `:lower` / `:json` modifiers. Implement them inline with the JS string APIs you already know: ```ts title="modifiers.ts" -const err = error({ +import { z } from "zod"; +import { error } from "@deessejs/errors"; + +const UserCreatedError = error({ name: "UserCreatedError", fields: z.object({ userId: z.string() }), message: (data) => `Created user: ${data.userId.toUpperCase()}`, }); + +const err = UserCreatedError({ userId: "usr_abc" }); +console.log(err.message); // -> Created user: USR_ABC ``` `JSON.stringify` replaces `:json`: ```ts title="json.ts" +import { z } from "zod"; +import { error } from "@deessejs/errors"; + const DataError = error({ name: "DataError", fields: z.object({ payload: z.object({ id: z.number(), name: z.string() }) }), message: (data) => `Invalid data: ${JSON.stringify(data.payload)}`, }); + +const err = DataError({ payload: { id: 1, name: "Ada" } }); +console.log(err.message); // -> Invalid data: {"id":1,"name":"Ada"} ``` -## Legacy: string template +## Deprecated: string template (removed in 2.0.0) -The legacy string-template form (`message: "Field {field} is invalid"`) still works in the current major. It is deprecated and will be removed in the next major. New code should prefer the function form above. +The string-template form (`message: "Field {field} is invalid"`) was deprecated in 1.4.0 and **removed in 2.0.0** for the schema overload. A factory declared with a string `message` is no longer accepted alongside a schema; the schema overload requires a function-form `message`. The legacy no-schema form (no `fields` schema) still accepts a string `message`, and renders it without placeholder substitution: ```ts title="legacy.ts" +import { error } from "@deessejs/errors"; + const ValidationError = error({ name: "ValidationError", message: "Field {field} is invalid", }); -const err = ValidationError({ field: "email" }); -console.log(err.message); // "Field email is invalid" +const err = ValidationError(); +console.log(err.message); // -> Field {field} is invalid ``` Behavior summary: -- Placeholders without a value are left as-is in the output, making missing data easy to spot. -- Modifier suffixes `:upper`, `:lower`, `:json` are still parsed and applied on top of the raw input. -- Schema validation, when supplied, runs before template rendering; failed validation throws `ArgsValidationError`. +- The function form is the only path that produces type-checked, post-transform fields. New code should use the function form. +- For no-schema factories, a string `message` is rendered verbatim. The function form is the recommended path. +- Schema validation, when supplied, runs before rendering; failed validation throws `ArgsValidationError`. ## See Also diff --git a/apps/web/content/docs/multiple-inheritance.mdx b/apps/web/content/docs/multiple-inheritance.mdx index 54ae026..e45e4f3 100644 --- a/apps/web/content/docs/multiple-inheritance.mdx +++ b/apps/web/content/docs/multiple-inheritance.mdx @@ -29,6 +29,8 @@ Now this error belongs to both categories in the hierarchy. When an error inherits from multiple parents, `is()` returns true for any of its parents: ```ts title="checking.ts" +import { error, is } from '@deessejs/errors'; + const NetworkError = error({ name: 'NetworkError' }); const StorageError = error({ name: 'StorageError' }); @@ -37,11 +39,13 @@ const NetworkStorageError = error({ inherits: [NetworkError, StorageError], }); -const err = NetworkStorageError({}); +// The no-schema factories have an empty input shape; the call +// takes no arguments. +const err = NetworkStorageError(); -console.log(is(err, NetworkStorageError)); // true -console.log(is(err, NetworkError)); // true -console.log(is(err, StorageError)); // true +console.log(is(err, NetworkStorageError)); // -> true +console.log(is(err, NetworkError)); // -> true +console.log(is(err, StorageError)); // -> true ``` This flexibility lets you categorize errors from multiple angles. @@ -76,7 +80,7 @@ function handleStorageIssue(err: unknown) { } } -const cacheErr = CacheError({}); +const cacheErr = CacheError(); handleNetworkIssue(cacheErr); // Logs (through NetworkError inheritance) handleStorageIssue(cacheErr); // Logs (through StorageError inheritance) @@ -86,33 +90,50 @@ This pattern is useful for middleware and infrastructure code that needs to resp ## Combining with Specific Error Data -Multiple inheritance works seamlessly with typed fields: +Multiple inheritance works with schema-bearing factories. Each parent declares its own schema; the leaf composes a schema whose output is assignable to **every** parent's output: ```ts title="with-fields.ts" +import { z } from 'zod'; import { error, is } from '@deessejs/errors'; -const NetworkError = error<{ endpoint: string }>({ +const NetworkError = error({ name: 'NetworkError', - message: 'Network request to {endpoint} failed', + fields: z.object({ endpoint: z.string() }), + message: (d) => `Network request to ${d.endpoint} failed`, }); -const StorageError = error<{ path: string }>({ +const StorageError = error({ name: 'StorageError', - message: 'Storage operation at {path} failed', + fields: z.object({ path: z.string() }), + message: (d) => `Storage operation at ${d.path} failed`, }); -const CacheError = error<{ key: string; operation: string }>({ +// The leaf's output must be assignable to every parent's output. +// The leaf's `fields` carries its own specific data, plus the +// keys the parents require. +const CacheError = error({ name: 'CacheError', + fields: z.object({ + endpoint: z.string(), + path: z.string(), + key: z.string(), + operation: z.string(), + }), + message: (d) => + `Cache ${d.operation} for key ${d.key} at ${d.endpoint} → ${d.path} failed`, inherits: [NetworkError, StorageError], - message: 'Cache {operation} for key {key} failed', }); -// The error carries its own specific data -const err = CacheError({ key: 'user:123', operation: 'read' }); +const err = CacheError({ + endpoint: '/api', + path: '/var/cache', + key: 'user:123', + operation: 'read', +}); console.log(err.fields.key); // "user:123" -console.log(err.message); // "Cache read for key user:123 failed" +console.log(err.message); // "Cache read for key user:123 at /api → /var/cache failed" -// But also inherits type checking for parents +// The leaf is also recognized as an instance of every parent. console.log(is(err, NetworkError)); // true console.log(is(err, StorageError)); // true ``` @@ -129,6 +150,65 @@ Multiple inheritance is appropriate when: Avoid using multiple inheritance just because you can. Prefer single inheritance for most errors, and only use multiple inheritance when the error genuinely belongs to multiple categories. +## Schema Validation Across Multiple Parents + +Multiple inheritance declares a static type-level relationship: the leaf's `InferOutput` must be assignable to **every** parent's `InferOutput`. The constraint is checked at the `error()` definition site — a violation is a TypeScript error, not a runtime throw. Each factory still runs its own schema; the parents' schemas do not validate the child's data. + +```ts title="composition.ts" +import { z } from 'zod'; +import { error } from '@deessejs/errors'; + +const NetworkError = error({ + name: 'NetworkError', + fields: z.object({ endpoint: z.string() }), + message: (d) => d.endpoint, +}); +const StorageError = error({ + name: 'StorageError', + fields: z.object({ path: z.string() }), + message: (d) => d.path, +}); + +// The consumer composes the leaf's schema to satisfy both +// parents. TypeScript verifies that the leaf's output is +// assignable to NetworkError's AND StorageError's. +const CacheError = error({ + name: 'CacheError', + fields: z.object({ endpoint: z.string(), path: z.string() }), + message: (d) => `${d.endpoint} → ${d.path}`, + inherits: [NetworkError, StorageError], +}); +``` + +If the leaf's output is not assignable to one of the parents, the call site errors: + +```ts title="sibling-incompatible.ts" +import { z } from 'zod'; +import { error } from '@deessejs/errors'; + +const A = error({ + name: 'A', + fields: z.object({ a: z.string() }), + message: (d) => d.a, +}); +const B = error({ + name: 'B', + fields: z.object({ b: z.number() }), + message: (d) => String(d.b), +}); + +// Type error: the leaf is missing `b` for B. +error({ + name: 'C', + fields: z.object({ a: z.string() }), + message: (d) => d.a, + // @ts-expect-error — child is missing b for B + inherits: [A, B], +}); +``` + +Diamond inheritance (`A ← B`, `A ← C`, `D.inherits: [B, C]`) is recognized for `is()` walks but does not cause any cascade. The leaf's schema is the only one that runs. + ## See Also @@ -138,4 +218,4 @@ Avoid using multiple inheritance just because you can. Prefer single inheritance Check error types including inherited ones. - \ No newline at end of file + diff --git a/apps/web/content/docs/recipes.mdx b/apps/web/content/docs/recipes.mdx index 3535500..ee9a21b 100644 --- a/apps/web/content/docs/recipes.mdx +++ b/apps/web/content/docs/recipes.mdx @@ -7,7 +7,7 @@ This page collects common patterns and practical examples for using @deessejs/er ## Validation Errors -Validation errors are one of the most common error types. They typically include the field that failed validation and a reason for the failure. +Validation errors are one of the most common error types. They typically include the field that failed validation and a reason for the failure. With a Standard Schema attached to the factory, the field shape is enforced at every call. ```ts title="validation.ts" import { error, raise } from '@deessejs/errors'; @@ -19,10 +19,17 @@ const UserSchema = z.object({ age: z.number().min(0).max(150), }); -// Create the error factory -const ValidationError = error<{ field: string; reason: string; value?: unknown }>({ +// Create the error factory. The schema's `InferOutput` is the +// factory's `fields` type; the `message` function receives +// the post-transform output. +const ValidationError = error({ name: 'ValidationError', - message: 'Validation failed for field "{field}"', + fields: z.object({ + field: z.string(), + reason: z.string(), + value: z.unknown().optional(), + }), + message: (data) => `Validation failed for field "${data.field}": ${data.reason}`, }); // Validation function @@ -36,7 +43,7 @@ function validateUser(data: unknown) { field: issue.path.join('.'), reason: issue.message, value: (result.error as { value?: unknown }).value, - }) + }), ); } @@ -48,18 +55,20 @@ When validation fails, you get an error with specific information about which fi ## Network Errors -Network errors benefit from including the endpoint and any relevant request data. +Network errors benefit from including the endpoint and any relevant request data. The schema's input is the call site shape; consumers can supply the response status without a separate field for it. ```ts title="network.ts" import { error, raise } from '@deessejs/errors'; +import { z } from 'zod'; -const NetworkError = error<{ - endpoint: string; - method?: string; - statusCode?: number; -}>({ +const NetworkError = error({ name: 'NetworkError', - message: 'Request to {endpoint} failed', + fields: z.object({ + endpoint: z.string(), + method: z.string().optional(), + statusCode: z.number().optional(), + }), + message: (data) => `Request to ${data.endpoint} failed`, }); async function fetchWithError(url: string) { @@ -70,7 +79,7 @@ async function fetchWithError(url: string) { NetworkError({ endpoint: url, statusCode: response.status, - }) + }), ); } @@ -82,32 +91,36 @@ You can extend this pattern to include request headers, body, or timing informat ## Database Errors -Database errors typically need the query that failed and any relevant identifiers. +Database errors typically need the query that failed and any relevant identifiers. The factory's schema keeps the input shape and the runtime output type in lockstep. ```ts title="database.ts" -import { error, raise } from '@deessejs/errors'; +import { error } from '@deessejs/errors'; +import { z } from 'zod'; -const DatabaseError = error<{ - operation: string; - table?: string; - query?: string; -}>({ +const DatabaseError = error({ name: 'DatabaseError', - message: 'Database {operation} failed', + fields: z.object({ + operation: z.string(), + table: z.string().optional(), + query: z.string().optional(), + }), + message: (data) => `Database ${data.operation} failed`, }); // Wrap database operations -async function executeQuery(query: string, table: string) { +async function executeQuery(db: { query: (q: string) => Promise }, query: string, table: string) { try { return await db.query(query); } catch (err) { - raise( - DatabaseError({ - operation: 'query', - table, - query, - }).from(err as Error) - ); + // `DatabaseError(...)` returns a fresh instance, then + // `.from(err)` chains the cause. `.from()` is `this`-returning. + // Re-throw so callers see the failure rather than a + // resolved promise with an error value. + throw DatabaseError({ + operation: 'query', + table, + query, + }).from(err instanceof Error ? err : new Error(String(err))); } } ``` @@ -116,13 +129,29 @@ Chaining the original error preserves the full context of what went wrong. ## Error Handling Middleware -Create a reusable error handler that processes errors based on their type. +Create a reusable error handler that processes errors based on their type. Each `is()` check narrows the error to a specific factory's `InferOutput`, so `err.fields.field`, `err.fields.endpoint`, etc. are all type-checked. ```ts title="handler.ts" -import { error, raise, is, causes } from '@deessejs/errors'; -import { ValidationError, NetworkError, DatabaseError } from './errors'; +import { error, is, causes } from '@deessejs/errors'; +import { z } from 'zod'; + +const ValidationError = error({ + name: 'ValidationError', + fields: z.object({ field: z.string() }), + message: (d) => `Invalid input: ${d.field}`, +}); + +const NetworkError = error({ + name: 'NetworkError', + message: () => 'External service unavailable', +}); -export function handleApiError(err: unknown) { +const DatabaseError = error({ + name: 'DatabaseError', + message: () => 'Database operation failed', +}); + +function handleApiError(err: unknown) { // Log the full error chain console.error('Error chain:', causes(err).map((e) => e.message)); @@ -159,32 +188,37 @@ This pattern centralizes error handling logic and ensures consistent responses. ## Service Layer Error Wrapping -When building services that call other services, wrap errors to add context while preserving the cause chain. +When building services that call other services, wrap errors to add context while preserving the cause chain. `.from()` replaces the previous direct cause; build the chain by attaching each layer's cause to the previous one. ```ts title="service.ts" -import { error, raise } from '@deessejs/errors'; -import { NetworkError, UserNotFoundError } from './errors'; +import { error } from '@deessejs/errors'; +import { z } from 'zod'; const AppError = error({ name: 'AppError' }); +const UserNotFoundError = error({ + name: 'UserNotFoundError', + fields: z.object({ userId: z.string() }), + message: (d) => `User ${d.userId} not found`, +}); + class UserService { - async getUser(userId: string) { + async getUser(userId: string, fetchUser: (id: string) => Promise) { try { - const user = await this.fetchUser(userId); + const user = await fetchUser(userId); if (!user) { - raise(UserNotFoundError({ userId })); + // `.raise()` is the idiomatic throw; here we use + // throw since we're in a class method. + throw UserNotFoundError({ userId }); } return user; } catch (err) { - // Wrap any error from lower layers - raise(AppError({}).from(err as Error)); + // Wrap any error from lower layers. `err` is `unknown` + // in strict mode; check before .from(err). + const cause = err instanceof Error ? err : new Error(String(err)); + throw AppError().from(cause); } } - - private async fetchUser(userId: string) { - // Implementation - return null; - } } ``` @@ -192,10 +226,11 @@ The caller gets both the context of the current operation and the underlying cau ## Hierarchical Error Categories -Create a hierarchy that lets you handle errors at different levels of granularity. +Create a hierarchy that lets you handle errors at different levels of granularity. `is()` walks the factory's `inherits` graph, so a single check can match a leaf and any of its ancestors. ```ts title="hierarchy.ts" import { error, is } from '@deessejs/errors'; +import { z } from 'zod'; // Top level const AppError = error({ name: 'AppError' }); @@ -204,26 +239,32 @@ const AppError = error({ name: 'AppError' }); const UserError = error({ name: 'UserError', inherits: AppError }); const ProductError = error({ name: 'ProductError', inherits: AppError }); -// Specific errors -const UserNotFoundError = error<{ userId: string }>({ +// Specific errors. Each leaf carries its own schema; the +// InferOutput stays scoped to that factory. +const UserNotFoundError = error({ name: 'UserNotFoundError', + fields: z.object({ userId: z.string() }), + message: (d) => `User ${d.userId} not found`, inherits: UserError, }); -const UserPermissionError = error<{ userId: string; action: string }>({ +const UserPermissionError = error({ name: 'UserPermissionError', + fields: z.object({ userId: z.string(), action: z.string() }), + message: (d) => `User ${d.userId} cannot ${d.action}`, inherits: UserError, }); function handleError(err: unknown) { if (is(err, AppError)) { - // Catches everything + // Catches everything in the AppError family. } if (is(err, UserError)) { - // Catches UserNotFoundError, UserPermissionError + // Catches UserNotFoundError, UserPermissionError. } if (is(err, UserNotFoundError)) { - // Catches only user not found errors + // Catches only user not found errors; `err.fields.userId` + // is typed as `string`. } } ``` @@ -245,4 +286,4 @@ This lets you write both broad handlers and specific ones depending on the situa Preserve error context. - \ No newline at end of file + diff --git a/apps/web/content/docs/single-inheritance.mdx b/apps/web/content/docs/single-inheritance.mdx index 2f765ad..9e93115 100644 --- a/apps/web/content/docs/single-inheritance.mdx +++ b/apps/web/content/docs/single-inheritance.mdx @@ -29,16 +29,20 @@ Now `ValidationError` is a child of `AppError` in the hierarchy. You can verify When an error inherits from another, `is()` returns true for both the error itself and all its ancestors: ```ts title="checking.ts" +import { error, is } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError', inherits: AppError, }); -const err = ValidationError({ field: 'email' }); +// The no-schema factories have an empty input shape; the call +// takes no arguments. +const err = ValidationError(); -console.log(is(err, ValidationError)); // true -console.log(is(err, AppError)); // true (through inheritance) +console.log(is(err, ValidationError)); // -> true +console.log(is(err, AppError)); // -> true ``` This is powerful for error handling — you can write a catch block for `AppError` and it will catch all errors that inherit from it, including `ValidationError`, `DatabaseError`, and any other descendants. @@ -73,8 +77,8 @@ function handleError(err: unknown) { } } -handleError(ValidationError({ field: 'email' })); // Logs -handleError(NetworkError({ url: '/api' })); // Logs +handleError(ValidationError()); // Logs +handleError(NetworkError()); // Logs ``` This pattern lets you handle errors at different levels of granularity depending on your needs. @@ -84,19 +88,21 @@ This pattern lets you handle errors at different levels of granularity depending You can combine inheritance with message templates for powerful error definitions: ```ts title="with-message.ts" -import { error, is } from '@deessejs/errors'; +import { z } from "zod"; +import { error, is } from "@deessejs/errors"; -const AppError = error({ name: 'AppError' }); +const AppError = error({ name: "AppError" }); -const ValidationError = error<{ field: string }>({ - name: 'ValidationError', +const ValidationError = error({ + name: "ValidationError", inherits: AppError, - message: 'Field "{field}" is invalid', + fields: z.object({ field: z.string() }), + message: (d) => `Field "${d.field}" is invalid`, }); -const err = ValidationError({ field: 'email' }); -console.log(err.message); // "Field "email" is invalid" -console.log(is(err, AppError)); // true +const err = ValidationError({ field: "email" }); +console.log(err.message); // -> Field "email" is invalid +console.log(is(err, AppError)); // -> true ``` ## Accessing Parent Information @@ -104,17 +110,120 @@ console.log(is(err, AppError)); // true The `inherits` property on the error factory lets you access parent types programmatically: ```ts title="parents.ts" -const AppError = error({ name: 'AppError' }); +import { error } from "@deessejs/errors"; + +const AppError = error({ name: "AppError" }); const ValidationError = error({ - name: 'ValidationError', + name: "ValidationError", inherits: AppError, }); -console.log(ValidationError.inherits === AppError); // true +console.log(ValidationError.inherits === AppError); // -> true ``` This is useful for building introspection tools or generating documentation automatically. +## Schema Validation Across the Chain + +Inheritance declares a static type-level relationship, not a runtime cascade. Each factory runs its own schema; the parent schema does **not** validate the child's data at instantiation. The `is()` walk continues to recognize the child as an instance of the parent for type-narrowing purposes, but `instance.fields` reflects only the leaf's schema output. + +```ts title="composition.ts" +import { error } from '@deessejs/errors'; +import { z } from 'zod'; + +// Parent carries a schema. +const Parent = error({ + name: 'Parent', + fields: z.object({ id: z.string() }), + message: (d) => d.id, +}); + +// The consumer composes the leaf's schema explicitly. The library +// does not merge two schemas for you. +const Leaf = error({ + name: 'Leaf', + fields: z.object({ id: z.string(), extra: z.string() }), + message: (d) => `${d.id}-${d.extra}`, + inherits: Parent, +}); +``` + +The TypeScript constraint enforces that `InferOutput` is assignable to `InferOutput`. The child may add fields but must not drop or change the parent's required ones: + +```ts title="incompatible-inheritance.ts" +import { z } from "zod"; +import { error } from "@deessejs/errors"; + +// Type error: { n: string } is not assignable to { n: number }. +const Parent = error({ + name: "Parent", + fields: z.object({ n: z.coerce.number() }), + message: (d) => String(d.n), +}); + +error({ + name: "Leaf", + fields: z.object({ n: z.string() }), + message: (d) => d.n, + // @ts-expect-error — { n: string } is not assignable to Parent's + // output { n: number } + inherits: Parent, +}); +``` + +The constraint catches every structural mismatch the previous runtime gates tried to enforce — object shape differences, literal value differences, array element differences, and nested structural differences — at the type level, before any code runs. + +## Composing a Parent's Schema + +To add fields to a parent's schema in the child, use your validator's primitives. The library does not pick a winner; the choice is yours. + +```ts title="compose-zod.ts" +import { z } from "zod"; +import { error } from "@deessejs/errors"; + +const Parent = error({ + name: "Parent", + fields: z.object({ registry: z.string() }), + message: (d) => d.registry, +}); + +const TemplateNotFound = error({ + name: "TemplateNotFound", + fields: z.object({ registry: z.string(), slug: z.string() }), + message: (d) => `${d.slug} not found in ${d.registry}`, + inherits: Parent, +}); +``` + +For Valibot, declare the parent schema separately and spread its entries into the leaf's schema. The factory exposes the schema via `.schema` (not `.fields` — that's the instance slot): + +```ts title="compose-valibot.ts" +import * as v from 'valibot'; +import { error } from '@deessejs/errors'; + +// Declare the parent schema as a reusable variable. The +// factory stores it on `.schema`; we don't read .schema +// back, we use the local reference. +const parentSchema = v.object({ registry: v.string() }); + +const Parent = error({ + name: 'Parent', + fields: parentSchema, + message: (d) => d.registry, +}); + +const TemplateNotFound = error({ + name: 'TemplateNotFound', + fields: v.object({ ...parentSchema.entries, slug: v.string() }), + message: (d) => `${d.slug} not found in ${d.registry}`, + inherits: Parent, +}); +``` + +For ArkType, use `pipe`, `and`, or `merge`. The point is the same: the consumer composes the schema, the library runs it on the input, and `inherits` declares the type-level relationship. + +The factory's `inherits` slot is frozen at construction time. The caller's array is **not** mutated in place — surprising the consumer with a side effect on a shared list was a documented footgun. The factory builds a single frozen snapshot once, and both the factory's metadata and every produced instance's `inherits` share the same reference. The classification invariant is preserved across caller-side mutations on the original list. + ## See Also @@ -127,4 +236,4 @@ This is useful for building introspection tools or generating documentation auto See the complete picture of how errors work together. - \ No newline at end of file + diff --git a/apps/web/content/docs/type-checking.mdx b/apps/web/content/docs/type-checking.mdx index 580258f..a85cc1e 100644 --- a/apps/web/content/docs/type-checking.mdx +++ b/apps/web/content/docs/type-checking.mdx @@ -18,7 +18,9 @@ const ValidationError = error({ inherits: AppError, }); -const err = ValidationError({ field: 'email' }); +// The no-schema factories have an empty input shape; the call +// takes no arguments. +const err = ValidationError(); console.log(is(err, ValidationError)); // true console.log(is(err, AppError)); // true (through inheritance) @@ -83,10 +85,13 @@ This lets you mix @deessejs/errors with standard JavaScript error handling seaml When `is()` returns true, TypeScript knows the error is of that specific type. This enables type-safe access to fields: ```ts title="type-safety.ts" +import { z } from "zod"; import { error, is } from '@deessejs/errors'; -const ValidationError = error<{ field: string; reason: string }>({ +const ValidationError = error({ name: 'ValidationError', + fields: z.object({ field: z.string(), reason: z.string() }), + message: (d) => `${d.field}: ${d.reason}`, }); function processError(err: unknown) { @@ -141,14 +146,15 @@ const ValidationError = error({ inherits: AppError, }); -const err = ValidationError({}); +const err = ValidationError(); // This works -console.log(is(err, ValidationError)); // true -console.log(is(err, AppError)); // true +console.log(is(err, ValidationError)); // -> true +console.log(is(err, AppError)); // -> true -// This doesn't work for @deessejs/errors -console.log(err instanceof ValidationError); // false +// `instanceof` does not work because @deessejs/errors +// factories are functions, not classes. Use `is()` instead. +console.log(err instanceof Error); // -> true ``` Always use `is()` instead of `instanceof` when working with @deessejs/errors errors. diff --git a/packages/errors/README.md b/packages/errors/README.md index 13b825e..79b2c28 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -36,19 +36,28 @@ npm install @deessejs/errors ## Usage ```typescript +import { z } from 'zod'; import { error, raise, is, causes } from '@deessejs/errors'; -// Define an error factory with a templated message +// Define a factory with a Standard Schema. The `message` is a +// function that receives the schema's *output* (post-transform). +// The factory's call signature requires the schema's *input*, +// so callers can supply the validated shape directly. const ValidationError = error({ name: 'ValidationError', - message: 'Field "{field}" is invalid: {reason}', + fields: z.object({ + field: z.string(), + reason: z.string(), + }), + message: (data) => `Field "${data.field}" is invalid: ${data.reason}`, }); -// Construct a typed error +// Construct a typed error. The schema validates the input. const err = ValidationError({ field: 'email', reason: 'invalid format' }); // err.message === 'Field "email" is invalid: invalid format' -// Chain a cause +// Chain a cause. .from() replaces the previous cause; the +// previous cause is reachable through its own .cause link. const cause = error({ name: 'NetworkError' })(); err.from(cause); @@ -70,11 +79,11 @@ causes(err); // [cause] ## Engine compatibility -| Runtime | Required | -| ---------- | -------------------- | -| Node.js | `>=22.14.0` | -| pnpm | `10` for development | -| TypeScript | `5.x` | +| Runtime | Required | +| ---------- | ---------------------------------------------------------------- | +| Node.js | `>=22.14.0` | +| pnpm | `10` for development | +| TypeScript | `5.4` or later (the public `error()` overloads use `NoInfer`) | ESM-only. Consumers using a CJS resolver need to use dynamic `import()` or migrate to ESM. diff --git a/packages/errors/package.json b/packages/errors/package.json index b3bf4f5..69cd9cb 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -31,6 +31,9 @@ "test:run": "vitest run", "build": "tsc -p tsconfig.build.json", "type-check": "tsc --noEmit", + "type-check:test": "tsc --noEmit -p tsconfig.test.json", + "test:consumer": "pnpm build && node tests/consumer-from-dist.mjs", + "test:docs": "pnpm build && node tests/docs-examples-typecheck.mjs", "lint": "eslint src/" }, "keywords": [ diff --git a/packages/errors/src/causes/index.ts b/packages/errors/src/causes/index.ts index b9d933f..8049fa7 100644 --- a/packages/errors/src/causes/index.ts +++ b/packages/errors/src/causes/index.ts @@ -3,17 +3,24 @@ */ /** - * Returns all causes in the error chain, from most recent to root cause. + * Walks the cause chain of an error, returning the chain from the + * immediate cause down to the root. * - * The function uses a structural guard (`'causes' in error && Array.isArray(error.causes)`) - * rather than a type cast. This is rule 0004 in operational form: the - * guard is named, the scenario it covers is named, and the input can be - * `unknown` without an `as ErrorInstance` cast at the call site. + * The chain is built by following `.cause` (with structural guards + * that accept any value, including native `Error.cause` and + * `ErrorInstance.cause`). Cycle detection via a `Set` ensures that + * a malformed cycle does not hang the process. * - * @param error - The error to get causes from (any value; `null` and - * `undefined` return `[]`) - * @returns Array of errors in the cause chain, ordered newest to - * oldest. Returns `[]` when the input does not carry a `causes` array. + * The returned array is a *new* array; mutations do not affect the + * underlying error. The `causes: Error[]` field that older versions + * of this package attached to each instance has been removed in + * Phase 4b. Walk the chain instead. + * + * @param error - The error to get causes from. `null` and + * `undefined` return `[]`. Native `Error` instances are accepted + * alongside `ErrorInstance`. + * @returns Array of errors in the cause chain, ordered immediate + * cause first, root cause last. * * @example * ```typescript @@ -32,24 +39,33 @@ * .from(new NetworkError('Connection failed')) * .from(new Error('DNS lookup failed')); * - * // causes(err) returns newest-to-oldest: [NetworkError, Error] - * // (err.cause is NetworkError, err.cause.cause is Error) + * // causes(err) returns [NetworkError, Error] + * // (err.cause is NetworkError, NetworkError.cause is Error) * ``` */ const causes = (error: unknown): Error[] => { - if (error == null) { + if (error == null || typeof error !== 'object') { return []; } - if (typeof error !== 'object') { - return []; - } + const result: Error[] = []; + const seen = new Set(); - if (!('causes' in error) || !Array.isArray(error.causes)) { - return []; + // Only follow `.cause` if it is itself an Error (or ErrorInstance). + // A primitive or non-Error object as `cause` is a malformed input + // (the runtime or upstream set it incorrectly); we stop the walk + // rather than include the malformed value in the result. + const rawCause = (error as { cause?: unknown }).cause; + let current: Error | null = rawCause !== null && rawCause instanceof Error ? rawCause : null; + + while (current !== null && !seen.has(current)) { + seen.add(current); + result.push(current); + const nextRaw = (current as { cause?: unknown }).cause; + current = nextRaw !== null && nextRaw instanceof Error ? nextRaw : null; } - return error.causes; + return result; }; export { causes }; diff --git a/packages/errors/src/error/capture.ts b/packages/errors/src/error/capture.ts index 0175200..e128aa3 100644 --- a/packages/errors/src/error/capture.ts +++ b/packages/errors/src/error/capture.ts @@ -1,40 +1,82 @@ /** * Stack trace capture utilities. - */ - -import { STACK_FRAME_PATTERN } from './constants.js'; - -/** - * Captures the current stack trace, cleaning up internal frames. * - * Uses Error.captureStackTrace in V8 environments for better performance. - * Falls back to string manipulation in other engines. + * Uses V8's `Error.captureStackTrace` when available (Node.js, Chrome, + * Edge, modern browsers). The optional second argument names a + * constructor above which frames are excluded — we pass `error` so + * callers see only the call site that invoked the factory, not + * the factory's own frame. In non-V8 engines, falls back to + * `new Error().stack` and post-processes the string to drop + * vendor-internal frames. * * @internal */ -const captureStack = (message: string): string => { - const stack = new Error().stack || ''; - const lines = stack.split('\n'); - const cleanedLines: string[] = [`Error: ${message}`]; - // Find start index (skip "Error: message" line) - let startIndex = 0; - for (let i = 0; i < lines.length; i = i + 1) { - if (STACK_FRAME_PATTERN.test(lines[i])) { - startIndex = i; - break; - } +// `Error.captureStackTrace` is a V8 extension, not in lib.dom or +// lib.es2022. We feature-detect it at module load time and cast +// through `unknown` to keep the call site terse. The `Function` +// type for the `exclude` parameter is intentional: V8's spec +// accepts any callable, and `Function` is the only single-token +// name in lib.es2022 that describes it without enumerating every +// conceivable signature. +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +type AnyFunction = Function; +const errorWithCapture = Error as unknown as { + captureStackTrace?: (target: object, exclude?: AnyFunction) => void; +}; + +const hasV8Capture = typeof errorWithCapture.captureStackTrace === 'function'; + +const captureStackV8 = (message: string, exclude: AnyFunction): string => { + // V8's captureStackTrace mutates the target in place to set + // `.stack`. The exclude argument drops frames above it in the + // call stack. We build a throwaway holder, capture into it, and + // return the resulting string. + const holder: { stack?: string } = {}; + errorWithCapture.captureStackTrace!(holder, exclude); + const raw = holder.stack ?? ''; + // V8's first line is the error name + message; replace it with + // our preferred header. The rest of the stack is already filtered + // by V8 itself. + const lines = raw.split('\n'); + if (lines.length === 0) { + return `Error: ${message}`; } + return [`Error: ${message}`, ...lines.slice(1)].join('\n'); +}; - // Filter internal frames - for (let i = startIndex; i < lines.length; i = i + 1) { - const line = lines[i]; +const captureStackFallback = (message: string): string => { + // Non-V8 engines: build a stack via the standard `Error` + // constructor and post-process the string to drop vendor + // frames. The same heuristic as before, kept for parity with + // the pre-Phase-11 behavior on engines that do not implement + // V8's captureStackTrace. + const stack = new Error(message).stack ?? ''; + const lines = stack.split('\n'); + const cleanedLines: string[] = [`Error: ${message}`]; + for (const line of lines) { if (line.includes('node_modules/@deessejs')) continue; if (line.includes('__vite')) continue; + if (line.trim().length === 0) continue; cleanedLines.push(line); } - return cleanedLines.join('\n'); }; +/** + * Captures the current stack trace as a string. + * + * The optional second argument is a constructor function whose + * frame (and above) should be excluded from the trace. Pass + * `error` to drop the factory's own frame. + * + * @internal + */ +const captureStack = (message: string, exclude?: AnyFunction): string => { + if (hasV8Capture && exclude) { + return captureStackV8(message, exclude); + } + return captureStackFallback(message); +}; + export { captureStack }; diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index c65d42f..28f12f9 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -6,7 +6,15 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; -import type { ErrorFactory, ErrorInstance } from './types.js'; +import type { + AnyErrorFactory, + ErrorFactory, + ErrorInstance, + IsObjectOutput, + ParentFor, + SchemaErrorFactory, +} from './types.js'; +import { acceptsFields } from './types.js'; import { captureStack } from './capture.js'; import { formatTemplate, hasTemplatePlaceholders } from './format.js'; @@ -16,6 +24,9 @@ import { formatTemplate, hasTemplatePlaceholders } from './format.js'; // The package ships pure ESM and intentionally does not depend on `@types/node` // at runtime. For this single use site we declare the narrow subset we need. +// `process` is read only inside `warnLegacy`, which guards the read with +// `typeof process !== 'undefined'` to survive environments where the +// identifier is not present (e.g. browser bundles without a polyfill). declare const process: | { env: Record; @@ -28,29 +39,90 @@ declare const process: /** * Symbol used to identify factory-created errors. - * Stored on the error instance to enable reliable instanceof checks. + * Stored on the error instance to enable reliable recognition by + * `is()`. Uses `Symbol.for` so the same key resolves across + * realms/bundles. * * @internal */ const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory'); +/** + * Private registry of every `ErrorInstance` produced by a factory + * in this package load. `is()` consults the registry to reject + * foreign objects that imitate the marker shape. + * + * The audit found that method-presence checks (`.from`, `.addNote`) + * are not a sound gate: a hand-rolled `Error` subclass with the + * right methods but no real `.fields` slips through. The registry + * is the **authoritative** identity: a candidate is a real + * `ErrorInstance` only if this package added it. + * + * The `WeakSet` keeps the memory profile clean: when the instance + * becomes unreachable, the entry is GC'd automatically. The set + * itself is module-private, so consumers cannot add to it from + * outside the package. + * + * **The registry is per-package-load.** An instance created by a + * second copy of `@deessejs/errors` (a duplicate in `node_modules`, + * a separate bundle, a CommonJS/ESM dual load, two copies running + * in a micro-frontend) is registered in *that* copy's `WeakSet`, + * not ours. Holding a direct reference to its factory is not + * sufficient: the marker slot is read and matches, but the + * registry check fails, and `is()` returns false. To recognize + * cross-load instances, the consumer must call `is()` from the + * same load that produced the instance. This is a deliberate + * trade-off: the marker alone is spoofable, the registry alone is + * not portable, and combining them makes forgeries impossible + * within a load at the cost of cross-load compatibility. + * + * @internal + */ +const INSTANCE_REGISTRY: WeakSet = new WeakSet(); + +/** + * Mark an `ErrorInstance` as registered with this package. Called + * exactly once, at construction time, by `buildErrorInstance`. + * + * @internal + */ +const registerInstance = (instance: object): void => { + INSTANCE_REGISTRY.add(instance); +}; + +/** + * Check whether a candidate is a registered instance produced by + * this package. The check is O(1) and never throws: a foreign + * object, a primitive, or `null` simply returns false. + * + * @internal + */ +const isRegisteredInstance = (candidate: unknown): candidate is object => { + if (candidate === null || typeof candidate !== 'object') return false; + return INSTANCE_REGISTRY.has(candidate); +}; + // ============================================================================ // Deprecation tracking // ============================================================================ /** - * Tracks call sites that still use the legacy message-template form. The - * runtime emits a single warning per site so consumers can find and migrate - * their `error({ name, message: 'string' })` calls. + * Tracks call sites that still use the legacy string-template form + * (`error({ name, message: 'Hello {name}' })`). The runtime emits a + * single warning per site so consumers can find and migrate. * * Set `process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES = '1'` to silence. * + * The warning is now scoped to the *template* form (string with + * `{field}` placeholders) only. A plain string `message` (no + * placeholders) and a function-form `message` do not warn. + * * @internal */ const warnedLegacyCallSites = new Set(); function warnLegacy(callSite: string): void { - const legacyGate = (process as { env?: Record } | undefined)?.env - ?.DEESSEJS_ERRORS_LEGACY_TEMPLATES; + if (typeof process === 'undefined') return; + const legacyGate = process.env?.DEESSEJS_ERRORS_LEGACY_TEMPLATES; if (legacyGate === '1') return; if (warnedLegacyCallSites.has(callSite)) return; warnedLegacyCallSites.add(callSite); @@ -67,47 +139,142 @@ function warnLegacy(callSite: string): void { // ============================================================================ /** - * Run a `StandardSchemaV1` validator and return either the validated output - * or the failure result. Mirrors the shape documented in `@standard-schema/spec`. - * - * The output is typed as `unknown` here; the caller (which knows the - * concrete `T`) is responsible for the cast. + * Run a `StandardSchemaV1` validator and return either the validated + * output or the failure result. Reuses `StandardSchemaV1.Result` and + * `StandardSchemaV1.Issue` from the spec rather than re-typing them. * * @internal */ function runSchema( schema: StandardSchemaV1, - input: unknown -): { ok: true; value: unknown } | { ok: false; issues: ReadonlyArray } { - const handle = schema; - const result = handle['~standard'].validate(input) as unknown; + input: unknown, + factoryName: string +): { ok: true; value: unknown } | { ok: false; issues: ReadonlyArray } { + const result = schema['~standard'].validate(input); if (result && typeof (result as Promise).then === 'function') { + // The schema returned a Promise, but error() is synchronous. We + // refuse to wait. The Promise itself is still in flight; attach a + // no-op catch so its rejection is contained. + (result as Promise).catch(() => undefined); throw new ArgsValidationError( - `Async schemas are not supported in \`error({...})\`. ` + - `Use \`schema\` directly (await) before instantiating.`, + factoryName, [{ message: 'Async validation not supported in error()' }], - handle['~standard'].vendor ?? 'unknown' + schema['~standard'].vendor ?? 'unknown' ); } - const r = result as { value?: unknown; issues?: unknown }; + const r = result as { value?: unknown; issues?: ReadonlyArray }; if (r && Array.isArray(r.issues)) { - return { ok: false, issues: r.issues as ReadonlyArray }; + return { ok: false, issues: r.issues }; } - return { ok: true, value: r.value as unknown }; + return { ok: true, value: r.value }; +} + +/** + * Runtime guard: a schema's validated output must be a non-null, + * non-array object. Schemas whose transformation returns a primitive, + * null, or array violate the public contract + * (`instance.fields: Record`); the consumer's + * downstream code would crash. + * + * The type-level gate `IsObjectOutput` rejects the same case at + * compile time. This guard is the runtime equivalent: it catches + * schemas whose output type passed the type check (e.g. through + * `any`) but whose value is malformed. + */ +function isObjectFields(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + return true; } // ============================================================================ // ArgsValidationError // ============================================================================ +/** + * Render a list of `StandardSchemaV1.Issue`s as a human-readable + * string. Reads each issue's `message` field (the standard guarantees + * one) and joins them with newlines. The `.path` is included when + * present so the consumer can locate the failing input. + * + * The function is defensive: a malformed issue (no `message`, + * non-string `message`) does not throw. The audit found that the + * previous `JSON.stringify(issues, null, 2)` would itself throw on a + * circular issue, hiding the `ArgsValidationError` behind a + * `TypeError`. + */ +const renderIssues = (issues: ReadonlyArray): string => { + const parts: string[] = []; + for (const issue of issues) { + if (issue === null || typeof issue !== 'object') { + parts.push(String(issue)); + continue; + } + const message = typeof issue.message === 'string' ? issue.message : ''; + const path = pathToString(issue.path); + parts.push(path ? `${path}: ${message}` : message); + } + return parts.join('\n'); +}; + +/** + * Render a `StandardSchemaV1.Issue.path` as a dotted string. + * + * The spec defines `path` as `ReadonlyArray`, + * where `PropertyKey = string | number | symbol` and `PathSegment` is + * `{ key: PropertyKey }`. Naive `.join('.')` would throw on a `symbol` + * key (`Cannot convert a Symbol value to a string`) and render + * `[object Object]` for an object segment. This helper extracts + * `.key` from object segments and `String()`s every primitive, so + * the result is always a human-readable dotted path. + * + * Examples: + * - `['email']` -> `"email"` + * - `['user', 'name']` -> `"user.name"` + * - `['items', 0, 'id']` -> `"items.0.id"` + * - `[{ key: 'email' }]` -> `"email"` + * - `[Symbol('id')]` -> `"Symbol(id)"` + * - `[null, undefined]` -> `""` (defensive against malformed issues) + */ +const pathToString = (path: unknown): string => { + if (!Array.isArray(path)) return ''; + return path + .map((segment) => { + if (segment === null || segment === undefined) return ''; + if (typeof segment === 'object') { + const key = (segment as { key?: unknown }).key; + return keyToString(key); + } + return keyToString(segment); + }) + .filter((s) => s.length > 0) + .join('.'); +}; + +const keyToString = (key: unknown): string => { + if (key === null || key === undefined) return ''; + if (typeof key === 'symbol') return key.toString(); + if (typeof key === 'string' || typeof key === 'number' || typeof key === 'boolean') { + return String(key); + } + return ''; +}; + /** * Thrown when args supplied to a Standard Schema-backed factory fail * validation. Wraps the validator's issues verbatim so consumers can * introspect or serialize them. * * Catching this error lets the consumer decide whether to surface a - * user-facing message, log to a structured sink, or convert to a different - * format. The validator's raw output is exposed via `.issues` and `.vendor`. + * user-facing message, log to a structured sink, or convert to a + * different format. The validator's raw output is exposed via + * `.issues` and `.vendor`. + * + * The `message` is built from the issues' `.message` fields rather + * than `JSON.stringify` so a circular issue (or any issue whose + * structure is hostile to JSON) does not turn the validation error + * into a `TypeError` from the formatter. * * @example * ```ts @@ -124,8 +291,8 @@ function runSchema( * ValidationError({ field: 1 as unknown as string }); * } catch (e) { * if (e instanceof Error && e.name === 'ArgsValidationError') { - * console.error(e.message); // "Argument validation failed for ValidationError: ..." - * console.error(e.issues); // raw issues + * console.error(e.message); + * console.error(e.issues); * } * } * ``` @@ -136,13 +303,17 @@ export class ArgsValidationError extends Error { /** The vendor of the Standard Schema that produced the failure. */ public readonly vendor: string; /** - * The validator's raw failure result. Typed loosely because each validator - * has its own issue shape; consult your validator's docs for details. + * The validator's raw failure result. Typed as + * `ReadonlyArray` so consumers can read + * `.message` and `.path` without re-casting. */ - public readonly issues: ReadonlyArray; - /** Internal constructor, but exported as a class so consumers can `instanceof`. */ - public constructor(source: string, issues: ReadonlyArray, vendor: string) { - super(`Argument validation failed for "${source}": ${JSON.stringify(issues, null, 2)}`); + public readonly issues: ReadonlyArray; + public constructor( + source: string, + issues: ReadonlyArray, + vendor: string + ) { + super(`Argument validation failed for "${source}": ${renderIssues(issues)}`); this.name = 'ArgsValidationError'; this.source = source; this.issues = issues; @@ -156,32 +327,135 @@ export class ArgsValidationError extends Error { // ============================================================================ /** - * Format the call-site string used in deprecation warnings. Inlined here - * (rather than importing `callsites`) to keep the bundle small. + * Format the call-site string used in deprecation warnings. * * @internal */ function formatCallSite(): string { const err = new Error(); const stack = err.stack ?? ''; - // Walk past the top frames (this function and its callers in error.ts) and - // capture the first userland frame. The format is V8-style - // " at file:line:col". const match = stack.match(/^\s+at\s+(.+?):\d+:\d+\s*$/m); if (match && match[1]) return match[1]; return 'unknown'; } /** - * Creates an error factory function for defining typed, structured errors. + * The normalized view of the public config that the implementation + * body operates on. The `(config: any)` signature at the + * implementation boundary exists so the strict public overloads are + * TS2394-assignable, but the body does not read field-level types + * from it. Destructuring into a `NormalizedConfig` confines the + * `any` to a single structural read: the four fields below are + * typed independently, so the cast does not propagate into the rest + * of the body. + * + * @internal + */ +interface NormalizedConfig { + name: string; + fields: StandardSchemaV1 | undefined; + inherits: AnyErrorFactory | readonly AnyErrorFactory[] | undefined; + message: string | ((data: unknown) => string) | undefined; +} + +const normalize = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + raw: any +): NormalizedConfig => ({ + name: typeof raw?.name === 'string' ? raw.name : '', + fields: raw?.fields, + inherits: raw?.inherits, + message: raw?.message, +}); + +/** + * Fields required to assemble a complete `ErrorInstance`. Lifted + * from the body of `error()` so the helper can construct the + * instance once, with all fields populated before any consumer + * reads it. The previous version assigned `.fields`, `.notes`, + * `.cause`, etc. on a `new Error(...)` cast, leaving a window + * where the type system believed the instance was complete but + * some fields were still undefined. * - * Two configurations are supported: + * @internal + */ +interface InstanceSeed { + name: string; + message: string; + fields: Record; + inherits: AnyErrorFactory | readonly AnyErrorFactory[] | undefined; + stack: string; +} + +/** + * Builds a complete `ErrorInstance` from a seed + the factory + * reference. The factory is attached via a non-writable property + * descriptor on the FACTORY_SYMBOL key, so the marker cannot be + * reassigned by a hostile object. Returns a value typed as the + * full `ErrorInstance>` extension (the + * body is type-erased; the precise shape is conveyed through + * the public overloads' return type). + */ +const buildErrorInstance = ( + factory: AnyErrorFactory, + seed: InstanceSeed +): ErrorInstance> => { + const instance = new Error(seed.message) as ErrorInstance>; + // The native Error carries its own `.message`; we don't reassign it. + Object.defineProperty(instance, 'name', { + value: seed.name, + writable: false, + enumerable: false, + configurable: false, + }); + instance.fields = seed.fields; + instance.notes = []; + instance.cause = null; + instance.context = null; + instance.inherits = seed.inherits; + instance.stack = seed.stack; + instance.from = (cause: Error): ErrorInstance> => { + instance.cause = cause; + return instance; + }; + instance.addNote = (note: string): ErrorInstance> => { + instance.notes.push(note); + return instance; + }; + // Non-writable marker: a consumer or hostile object cannot + // reassign this slot to make the instance pass `is()` checks + // for a different factory. + Object.defineProperty(instance, FACTORY_SYMBOL, { + value: factory, + writable: false, + enumerable: false, + configurable: false, + }); + // R9: register the instance in the package-private registry. + // `is()` consults the registry as the authoritative identity + // (the marker alone is spoofable; the registry is not). + registerInstance(instance); + return instance; +}; + +/** + * Creates an error factory function for defining typed, structured + * errors. Two configurations are supported: * * **Standard path** (RFC 0001): pass `fields: standardSchema` and a - * function-form `message`. Args are validated at instantiation. + * function-form `message`. Args are validated at instantiation. The + * schema's `InferOutput` is constrained to a non-null, non-array + * object at the type level via `ObjectOutputSchema`, and a runtime + * guard rejects malformed outputs. + * + * **Legacy path** (deprecated in 1.4.0, removed in 2.0.0): pass a + * string `message`. No validation runs. * - * **Legacy path** (deprecated in 1.4.0, removed in 2.0.0): pass a string - * `message`. No validation runs. + * The schema overload returns `SchemaErrorFactory`, whose + * call signature is *not* conditional: a factory with a schema + * always requires its input, even for `z.object({})`. The no-schema + * overload returns `ErrorFactory`, the legacy form, which keeps + * the optional-input form for the no-input legacy case. * * @param config - Error configuration * @@ -210,88 +484,166 @@ function formatCallSite(): string { * }); * ``` */ -export function error = Record>(config: { +// R7 + R8 + R9 + R10: the schema overload constrains the schema's +// output to a non-null, non-array object via a structural +// intersection on the `fields` parameter. The R10 audit found +// that a pure-type reject at the call site **is** possible, by +// intersecting `S` with a phantom that resolves to `never` when +// the predicate is false: +// +// fields: S & (IsObjectOutput> extends true +// ? unknown +// : never); +// +// When the user supplies a schema with `InferOutput = null`, the +// intersection becomes `S & never = never`, and the call errors: +// the user's value does not satisfy the parameter type. The +// earlier diagnosis ("TypeScript's function-arity flexibility +// prevents a type-level reject") was incorrect — that diagnosis +// applied to a constraint on the `message` arity, not on +// `fields`. The audit's repro confirms the gate fires for +// `null`, `unknown[]`, and primitive outputs while preserving +// the input/output inference for record outputs. +// +// The `inherits` parameter is constrained via `ParentFor>` +// (a contravariance witness — requires `strictFunctionTypes`). + +export function error(config: { + name: string; + fields: S & (IsObjectOutput> extends true ? unknown : never); + message: (data: StandardSchemaV1.InferOutput) => string; + inherits?: + | ParentFor>> + | readonly ParentFor>>[]; +}): SchemaErrorFactory, StandardSchemaV1.InferOutput>; + +export function error = Record>(config: { name: string; - fields?: StandardSchemaV1; + fields?: undefined; message?: string | ((data: T) => string); - inherits?: ErrorFactory | ErrorFactory[]; -}): ErrorFactory { - const { name, fields, inherits, message } = config; + inherits?: ParentFor> | readonly ParentFor>[]; +}): ErrorFactory; - // Decide API mode up front and surface call sites early so the deprecation - // warning points at the user's call. - const isStandard = fields !== undefined && typeof message === 'function'; +// The implementation signature is `(config: any)` because the public +// overloads (above) must be assignable to it (TS2394-safe). The +// `any` is an explicit internal boundary: the constraint runs at +// the public overloads' parameter types, not at the implementation. +// The body destructures through `normalize()` so the `any` is read +// once and confined; the rest of the body operates on a typed +// `NormalizedConfig`. +export function error( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: any +): AnyErrorFactory { + const { name, fields, inherits, message } = normalize(config); + + // R9: build the inherits snapshot once, before any closure + // captures it. The factory metadata and every produced instance + // share the same frozen reference. The previous version froze + // a copy on the factory but let the closure pass the caller's + // original list to the instance — a caller-side mutation on + // the shared list would still be visible on `instance.inherits`. + // Sharing the snapshot closes that gap. + // + // The cast strips `Readonly<...>` from `Object.freeze`'s + // widened return type so the snapshot stays assignable to the + // non-readonly parameter type the rest of the body uses. The + // runtime freezing is what protects against caller mutations; + // the type is a convenience, not a contract. + const inheritsSnapshot: AnyErrorFactory | readonly AnyErrorFactory[] | undefined = + inherits === undefined + ? undefined + : Array.isArray(inherits) + ? (Object.freeze([...inherits]) as readonly AnyErrorFactory[]) + : (Object.freeze(inherits) as AnyErrorFactory); + + const hasSchema = fields !== undefined; + const hasFunctionMessage = typeof message === 'function'; + + const ErrorFactoryInstance: ErrorFactory, Record> = ( + input?: Partial> + ): ErrorInstance> => { + // Runtime safety net: when a factory carries a schema, the + // input is required at the call site. The schema path now also + // has a non-optional call signature (see `SchemaErrorFactory`), + // so this is the safety net for JS callers and any + // `error({fields: schema})()` call that bypassed the type + // system. + if (input === undefined && hasSchema) { + throw new TypeError( + `error("${name}") was called with no arguments. The factory ` + + `carries a schema, so the input shape is required. Pass ` + + `the validated input, e.g. ${name}({ ... }).` + ); + } - /** - * Error factory function - creates error instances. - */ - const ErrorFactoryInstance: ErrorFactory = (input?: Partial): ErrorInstance => { let fieldsData: Record = {}; let errorMessage = name; - if (isStandard) { - if (fields === undefined || typeof message !== 'function') { - // Unreachable at runtime; the overloads guarantee both are present. - throw new Error('Internal: standard mode without fields or message function'); + if (hasSchema) { + if (fields === undefined) { + throw new Error('Internal: schema branch entered without fields'); } - const result = runSchema(fields, input); + const result = runSchema(fields, input, name); if (!result.ok) { + throw new ArgsValidationError(name, result.issues, fields['~standard'].vendor); + } + if (!isObjectFields(result.value)) { + // Runtime mirror of the type-level `IsObjectOutput` gate. + // A schema whose validated output is null, a primitive, or + // an array violates the `instance.fields: Record` contract. The previous version silently coerced + // to `{}` via `?? {}`, hiding the bug from the consumer. throw new ArgsValidationError( name, - result.issues as ReadonlyArray, + [ + { + message: + 'Schema output must be a non-null object. The transformation returned ' + + (result.value === null + ? 'null' + : Array.isArray(result.value) + ? 'an array' + : `a ${typeof result.value} value`) + + '.', + }, + ], fields['~standard'].vendor ); } - fieldsData = (result.value as Record) ?? {}; - errorMessage = (message as (data: T) => string)(fieldsData as unknown as T); + fieldsData = result.value; + if (hasFunctionMessage && typeof message === 'function') { + errorMessage = (message as (data: Record) => string)(fieldsData); + } } else { - // Legacy path — coerce input and interpolate the template if any. - fieldsData = (input && typeof input === 'object' ? input : {}) as Record; + // Legacy path — no schema. Accepts a string template, a plain + // string, or a function-form message. + if (input !== undefined && typeof input === 'object' && input !== null) { + fieldsData = input as Record; + } if (typeof message === 'string' && hasTemplatePlaceholders(message)) { + // The legacy deprecation is for the *template* form: a + // string `message` with `{field}` placeholders. A plain + // string (no placeholders) and a function-form message do + // not warn. + warnLegacy(formatCallSite()); errorMessage = formatTemplate(message, fieldsData); } else if (typeof message === 'string') { errorMessage = message; + } else if (hasFunctionMessage && typeof message === 'function') { + errorMessage = (message as (data: Record) => string)(fieldsData); } - // The deprecation marker is gated by the warning once per call site. - // Set `process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES = "1"` to silence. - warnLegacy(formatCallSite()); } - // Capture stack trace - const stack = captureStack(errorMessage); - - // Create error instance using native Error - const instance = new Error(errorMessage) as ErrorInstance; - instance.name = name; - instance.fields = fieldsData as unknown as T; - instance.notes = []; - instance.cause = null; - instance.causes = []; - instance.context = null; - instance.inherits = inherits ?? undefined; - instance.stack = stack; - - // Add .from() method for exception chaining - instance.from = (cause: Error): ErrorInstance => { - // Build new causes array: [new cause] + [cause's causes] + [existing causes of instance] - // This maintains chronological order: newest first - const causeCauses = 'causes' in cause && Array.isArray(cause.causes) ? cause.causes : []; - instance.causes = [cause, ...causeCauses, ...instance.causes]; - instance.cause = cause; - return instance; - }; - - // Add .addNote() method for runtime context (PEP 678) - instance.addNote = (note: string): ErrorInstance => { - instance.notes.push(note); - return instance; - }; - - // Mark this instance as created by this factory (for is() checks) - (instance as unknown as Record unknown>)[FACTORY_SYMBOL] = - ErrorFactoryInstance; + const stack = captureStack(errorMessage, ErrorFactoryInstance); - return instance; + return buildErrorInstance(ErrorFactoryInstance, { + name, + message: errorMessage, + fields: fieldsData, + inherits: inheritsSnapshot, + stack, + }); }; // Attach metadata to the factory function @@ -302,18 +654,47 @@ export function error = Record).inherits = inherits; + // R7 compatibility witness: a hidden function-typed property used + // by the type checker to enforce inheritance compatibility + // contravariantly. The function is never called at runtime. + Object.defineProperty(ErrorFactoryInstance, acceptsFields, { + value: (_fields: unknown) => undefined, + writable: false, + enumerable: false, + configurable: false, + }); + + // R9: the snapshot built above is the single source of truth + // for the parents list. The factory's metadata and every + // produced instance share the same frozen reference. R8 froze + // a separate copy here and let the closure pass the caller's + // original list to instances; that left `instance.inherits` + // exposed to caller-side mutations on the shared list. Sharing + // the snapshot closes the gap. + if (inheritsSnapshot !== undefined) { + ( + ErrorFactoryInstance as ErrorFactory, Record> + ).inherits = inheritsSnapshot; } if (fields !== undefined) { - (ErrorFactoryInstance as ErrorFactory).schema = fields; + ( + ErrorFactoryInstance as ErrorFactory, Record> + ).schema = fields; } if (message !== undefined) { - (ErrorFactoryInstance as ErrorFactory).rawMessage = message; + ( + ErrorFactoryInstance as ErrorFactory, Record> + ).rawMessage = message; } + // Freeze the factory's metadata. The factory's `name`, `inherits`, + // and `schema` are part of the type contract and must not change + // after construction. The `rawMessage` and the function name are + // already non-writable via defineProperty above. + Object.freeze(ErrorFactoryInstance); + return ErrorFactoryInstance; } @@ -321,4 +702,4 @@ export function error = Record