diff --git a/.changeset/fix-type-validation-phase-1.md b/.changeset/fix-type-validation-phase-1.md new file mode 100644 index 0000000..5876c06 --- /dev/null +++ b/.changeset/fix-type-validation-phase-1.md @@ -0,0 +1,160 @@ +--- +"@deessejs/errors": major +--- + +chore: tighten type, validation, and runtime contracts (audit Phases 1–6) + +Addresses the type-side and runtime-side findings of the self-audit. + +**Type contracts (Phases 1, 2, 6)** + +- Activates typecheck on the test files. A new `tsconfig.test.json` + extends the base config and includes `src` and `tests`. A new + `type-check:test` npm script runs it. +- Separates `ErrorFactory` into `` so the input + contract (what the caller passes) and the output contract (what + `.fields` carries after validation) can differ when a schema + transforms. Adds `InferStandardSchemaInput` alongside the + existing `InferStandardSchemaOutput`. +- Adds `AnyErrorFactory = ErrorFactory` for variance- + tolerant lists. The `inherits` field, the `is()` discriminator, + and the config unions all switch to this alias. Concrete + factories with different `T`/`O` generics now compose + correctly. +- Splits `is()` into two overloads. The factory overload returns + `ErrorInstance>`; the native-constructor + overload returns `Error`. The previous single signature falsely + promised `.fields`, `.notes`, `.from()`, and `.addNote()` on + native error instances. + +**Validation (Phase 3)** + +- 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 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. + +**Runtime stability (Phase 4)** + +- 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`. + +**Consumer smoke tests (Phase 5)** + +- 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. +- New `tests/raise-typed.test.ts` pins the public contract of + `raise()` and the `is()` discrimination. + +**Cause semantics (Phase 4b) — breaking** + +- The flat `causes: Error[]` field on `ErrorInstance` has been + removed. The previous field conflated historical `.from()` calls + with a true causal chain. +- `cause: Error | null` is now the only direct field. Each + `.from()` call replaces the previous cause; the chain of + previous causes is reachable through their own `.cause` links. +- `causes(error)` now walks the chain by following each cause's + own `.cause` link, with cycle detection. The returned array + is a new copy on each call. + +**Validation (Phase 3) — breaking** + +- A Standard Schema without a function-form `message` is no + longer accepted by the public signature. The schema overload + requires `message: (data) => string`. Consumers that relied + on `{ fields: schema }` without `message` (where the legacy + string-template form was used) must add a function message + or a string message without a schema. + +**Factory call signature — breaking** + +- The previous `error()` factory accepted `Partial` so any + field could be omitted at the call site. The new signature + accepts `T` (or `TInput` when a schema is supplied). Callers + that relied on `Partial` must now either supply the full + shape or annotate the field as optional in the schema. + +**Required input argument — breaking** + +- `ErrorFactory` now requires the input argument + at the call site whenever `TInput` is not the empty shape. 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 so consumers who bypass + the type-checker still get a clear message. Legacy call patterns + (`error({ name })`, `error({ name, message: 'literal' })`) keep + the optional argument because their default `TInput` is the empty + shape. + +**Inheritance guarantees structure — breaking** + +- A factory declared with `inherits: Parent` is now required to + produce a `fields` shape that satisfies every reachable ancestor + that carries a schema. At instantiation, the child's fields are + re-validated against each ancestor's schema in a depth-first walk + rooted at the current factory. The walk reads from the frozen + `factory.inherits` snapshot (the same source of truth that `is()` + reads from), uses a `Set` cycle guard shared + across siblings, and applies each ancestor's transformed output + to `data` as it cascades. Failure throws `ArgsValidationError` + with `source: `; the leaf's `instance.fields` + reflects every parent's transformation in the same order the + parents appear in `inherits`. +- `is(child, Parent)` and `is(child, Child)` are now honest at the + type level — the narrowed fields type is the intersection of the + factory's own output and all reachable ancestors' outputs — and + the runtime validation block enforces the same contract: a + factory whose `fields` do not satisfy an ancestor's schema + cannot produce a classify-able instance. +- The caller's `inherits` array is now `Object.freeze`d in place + at construction time (in addition to the Phase 4 freeze of the + factory's internal copy). Any in-place mutation of the array + after construction throws `TypeError` in strict mode. The + earlier implementation only froze the snapshot, leaving a + window where mutating the caller's array between factory + construction and the first invocation could desynchronize + the validation block (which read the closure) from `is()` + (which read the snapshot). + +**Function-form `message` without a schema** + +- 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. The audit's P2 finding + is closed; `error<{name: string}>({ name, message: d => d.name })` + now produces instances whose `.message` matches the function + output. + +**V8 stack capture (Phase 11)** + +- `Error.captureStackTrace(target, exclude)` is now used on V8 + engines. The factory passes the closure as the exclude + argument, so the captured trace contains only the call site + of the factory invocation, not the factory's own frames. + Non-V8 engines fall back to the previous string-based filter. + +The schema-driven input-shape inference (Phase 2's second half) +remains to be addressed in a follow-up PR. The current public +signature requires a function-form `message` when a schema is +supplied; the type tests carry `@ts-expect-error` markers +pointing at the missing input inference. + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5605560..ab82feb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,7 +126,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 +168,78 @@ 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 + # ============================================================================ # Changeset required (PRs to staging only) # ============================================================================ diff --git a/apps/web/content/docs/error-factory.mdx b/apps/web/content/docs/error-factory.mdx index db6e253..9551b7b 100644 --- a/apps/web/content/docs/error-factory.mdx +++ b/apps/web/content/docs/error-factory.mdx @@ -91,7 +91,7 @@ console.log(AppError.name); // "AppError" console.log(AppError.inherits); // undefined (no parent) ``` -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 diff --git a/apps/web/content/docs/error-instance.mdx b/apps/web/content/docs/error-instance.mdx index a201866..0638fbd 100644 --- a/apps/web/content/docs/error-instance.mdx +++ b/apps/web/content/docs/error-instance.mdx @@ -58,7 +58,10 @@ const appErr = AppError({}); appErr.from(validationErr); console.log(appErr.cause === validationErr); // true -console.log(appErr.causes.length); // 1 +// The full chain is reachable via the causes() helper, which +// walks the cause links and returns the chain immediate-cause first. +import { causes } from '@deessejs/errors'; +console.log(causes(appErr).length); // 1 ``` The `from()` method returns the error instance, allowing you to chain multiple calls or combine it with other operations. @@ -84,6 +87,8 @@ When no cause has been set, `cause` is `null`. The `causes` array contains the entire chain of errors, ordered from most recent to oldest. This gives you the full history of what happened. ```ts title="causes-array.ts" +import { causes, error } from '@deessejs/errors'; + const AppError = error({ name: 'AppError' }); const ValidationError = error({ name: 'ValidationError' }); @@ -91,12 +96,14 @@ const appErr = AppError({}); appErr.from(ValidationError({ field: 'email' })); appErr.from(new Error('Database connection failed')); -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" (most recent) +console.log(chain[1].message); // "Database connection failed" (oldest) ``` -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 diff --git a/apps/web/content/docs/from-method.mdx b/apps/web/content/docs/from-method.mdx index e2128ac..4542039 100644 --- a/apps/web/content/docs/from-method.mdx +++ b/apps/web/content/docs/from-method.mdx @@ -31,25 +31,31 @@ 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, the previous cause itself carries its own `.cause` — walk the chain with the `causes()` helper, which returns 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 +// Build the chain: each .from() sets the direct cause. appErr.from(ValidationError({ field: 'email' })); appErr.from(new Error('Database connection failed')); -// 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" +// The direct cause is the last .from() call. +console.log(appErr.cause?.message); // "Database connection failed" + +// Walk the chain — the previous cause is reachable transitively. +const chain = causes(appErr); +console.log(chain.length); // 2 — [ValidationError, Error] +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. The `causes()` helper detects cycles. ## Chaining with raise() @@ -87,7 +93,7 @@ try { ## 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'; @@ -99,14 +105,15 @@ const appErr = AppError({}); appErr.from(ValidationError({ field: 'email' })); appErr.from(new Error('Connection timeout')); -// 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 — [ValidationError, 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..9854155 100644 --- a/apps/web/content/docs/index.mdx +++ b/apps/web/content/docs/index.mdx @@ -47,6 +47,8 @@ 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" +import { causes, error } from '@deessejs/errors'; + const validationErr = ValidationError({}); const appErr = error({ name: 'AppError' })(); @@ -54,7 +56,8 @@ const appErr = error({ name: 'AppError' })(); 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 diff --git a/apps/web/content/docs/multiple-inheritance.mdx b/apps/web/content/docs/multiple-inheritance.mdx index 54ae026..45a9452 100644 --- a/apps/web/content/docs/multiple-inheritance.mdx +++ b/apps/web/content/docs/multiple-inheritance.mdx @@ -129,6 +129,61 @@ 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'; + +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" +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, + inherits: [A, B], + // @ts-expect-error — child is missing b for 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 +193,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/single-inheritance.mdx b/apps/web/content/docs/single-inheritance.mdx index 2f765ad..f91cb05 100644 --- a/apps/web/content/docs/single-inheritance.mdx +++ b/apps/web/content/docs/single-inheritance.mdx @@ -115,6 +115,95 @@ 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" +// 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<{ n: string }>({ + name: 'Leaf', + inherits: Parent, + // @ts-expect-error — manual generic { n: string } is not + // assignable to Parent's output { n: number } +}); +``` + +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'; + +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: + +```ts title="compose-valibot.ts" +import * as v from 'valibot'; + +const Parent = error({ + name: 'Parent', + fields: v.object({ registry: v.string() }), + message: (d) => d.registry, +}); + +const TemplateNotFound = error({ + name: 'TemplateNotFound', + fields: v.object({ ...Parent.fields, slug: v.string() } as never), + 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 `inherits` array is frozen at construction time, so any in-place mutation after `error()` returns throws `TypeError`. + ## See Also @@ -127,4 +216,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/packages/errors/package.json b/packages/errors/package.json index b3bf4f5..4a437f0 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -31,6 +31,8 @@ "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", "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