diff --git a/.changeset/rfc-0001-standard-schema.md b/.changeset/rfc-0001-standard-schema.md new file mode 100644 index 0000000..90500b1 --- /dev/null +++ b/.changeset/rfc-0001-standard-schema.md @@ -0,0 +1,13 @@ +--- +'@deessejs/errors': minor +--- + +Add `StandardSchemaV1` runtime validation and message-as-function mode to `error()` (RFC 0001). + +- New API: pass `fields: StandardSchemaV1` (Zod, Valibot, ArkType, etc.) and a function `message: (data) => string`. Args are validated at instantiation; invalid inputs throw `ArgsValidationError`. +- The function form receives the **parsed** (post-transform) data, so schemas that brand, coerce, or refine work as expected. +- New export `ArgsValidationError` with `source`, `vendor`, `issues`. Re-exported from `@deessejs/errors` so consumers can `instanceof`-check. +- `ErrorFactory.schema` has been removed. The duplication between `fields` and `schema` is gone. +- The legacy string-template form (`message: "Field {field}"`) keeps working in 1.x and emits a single deprecation warning per call site. Set `DEESSEJS_ERRORS_LEGACY_TEMPLATES=1` to silence. The legacy form will be removed in 2.0.0. + +See [RFC 0001](https://github.com/deessejs/errors/blob/main/docs/internal/engineering/rfcs/0001-standard-schema-fields.md) for the full design discussion. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b84103..8c985f4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,8 +12,14 @@ concurrency: jobs: test: - name: Tests - runs-on: ubuntu-latest + name: Tests (${{ matrix.os }} / node ${{ matrix.node }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [20, 22, 24] steps: - name: Checkout @@ -25,14 +31,14 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node }} cache: 'pnpm' - name: Cache Turborepo uses: actions/cache@v4 with: path: .turbo - key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + key: turbo-${{ matrix.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - name: Install dependencies run: pnpm install diff --git a/.github/workflows/types.yml b/.github/workflows/types.yml index 1c29d80..a65f8ae 100644 --- a/.github/workflows/types.yml +++ b/.github/workflows/types.yml @@ -12,9 +12,14 @@ concurrency: jobs: type-check: - name: 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 @@ -25,14 +30,14 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node }} cache: 'pnpm' - name: Cache Turborepo uses: actions/cache@v4 with: path: .turbo - key: turbo-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + key: turbo-${{ matrix.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - name: Install dependencies run: pnpm install diff --git a/apps/web/content/docs/api-reference.mdx b/apps/web/content/docs/api-reference.mdx index 3622e33..10ef065 100644 --- a/apps/web/content/docs/api-reference.mdx +++ b/apps/web/content/docs/api-reference.mdx @@ -3,57 +3,62 @@ title: API Reference description: Complete API reference for all exports from @deessejs/errors. --- -This page documents all public exports from @deessejs/errors. Use this as a comprehensive reference for the library's API. +This page documents all public exports from @deessejs/errors. ## error() Creates an error factory function for defining typed, structured errors. -```ts title="title="${f%.mdx}.ts"" -const errorFactory = error(config) +```ts title="factory.ts" +const errorFactory = error(config) ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| -| `config.name` | `string` | Error name identifier (required) | -| `config.message` | `string` | Message template with `{field}` placeholders | -| `config.fields` | `StandardSchemaV1` | Field schema for validation (Zod, Valibot, etc.) | -| `config.inherits` | `ErrorFactory \| ErrorFactory[]` | Parent error(s) to inherit from | +| 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 | + +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 \| ErrorFactory[] \| undefined` | Parent error types | -| `schema` | `StandardSchemaV1 \| undefined` | Field validation schema | -| `rawMessage` | `string \| undefined` | Original message template | +| 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 | ### Example -```ts title="title="${f%.mdx}.ts"" -import { error } from '@deessejs/errors'; +```ts title="factory.ts" +import { z } from "zod"; +import { error } from "@deessejs/errors"; -const ValidationError = error<{ field: string }>({ - name: 'ValidationError', - message: 'Field "{field}" is invalid', +const ValidationError = error({ + name: "ValidationError", + fields: z.object({ field: z.string() }), + message: (data) => 'Field "' + data.field + '" is invalid', inherits: AppError, }); -const err = ValidationError({ field: 'email' }); +const err = ValidationError({ field: "email" }); ``` + --- ## raise() -Throws an error instance. This is the primary mechanism for throwing errors in @deessejs/errors. +Throws an error instance. The primary mechanism for throwing errors in @deessejs/errors. -```ts title="title="${f%.mdx}.ts"" +```ts title="raise.ts" raise(error: ErrorInstance): never ``` @@ -61,18 +66,18 @@ raise(error: ErrorInstance): never | Parameter | Type | Description | |-----------|------|-------------| -| `error` | `ErrorInstance` | The error to throw | +| error | ErrorInstance | The error to throw | ### Returns -`never` — This function always throws. +never - this function always throws. ### Example -```ts title="title="${f%.mdx}.ts"" -import { error, raise } from '@deessejs/errors'; +```ts title="raise.ts" +import { error, raise } from "@deessejs/errors"; -const ValidationError = error({ name: 'ValidationError' }); +const ValidationError = error({ name: "ValidationError" }); raise(ValidationError({})); ``` @@ -81,47 +86,51 @@ raise(ValidationError({})); ## is() -Type guard function to check if an error is an instance of a specific error type. +Type guard function to check if an error is an instance of a specific error type. Supports single and multiple inheritance hierarchies. -```ts title="title="${f%.mdx}.ts"" -const result = is(error, ErrorType) +```ts title="is.ts" +const result = is(error: unknown, ErrorType): error is ErrorInstance ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| -| `error` | `unknown` | The error to check | -| `ErrorType` | `ErrorFactory \| ErrorClass` | The error type to check against | +| error | unknown | The error to check | +| ErrorType | ErrorFactory or ErrorClass | The error type to check against | ### Returns -`boolean` — `true` if the error matches or inherits from the type. +boolean - true if the error matches or inherits from the type. Narrows the input type for TypeScript. ### Example -```ts title="title="${f%.mdx}.ts"" -import { error, is } from '@deessejs/errors'; +```ts title="is.ts" +import { error, is } from "@deessejs/errors"; -const AppError = error({ name: 'AppError' }); +const AppError = error({ name: "AppError" }); const ValidationError = error({ - name: 'ValidationError', + name: "ValidationError", inherits: AppError, }); const err = ValidationError({}); -is(err, ValidationError); // true -is(err, AppError); // true (through inheritance) +if (is(err, ValidationError)) { + console.log("ValidationError matched"); +} +if (is(err, AppError)) { + console.log("AppError matched via inheritance"); +} ``` --- ## causes() -Returns all causes in an error chain. +Returns all causes in an error chain, ordered newest to oldest. -```ts title="title="${f%.mdx}.ts"" +```ts title="causes.ts" const chain = causes(error: unknown): Error[] ``` @@ -129,50 +138,106 @@ const chain = causes(error: unknown): Error[] | Parameter | Type | Description | |-----------|------|-------------| -| `error` | `unknown` | The error to get causes from | +| error | unknown | The error to get causes from | ### Returns -`Error[]` — Array of errors in the cause chain, ordered newest to oldest. 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. ### Example -```ts title="title="${f%.mdx}.ts"" -import { error, causes } from '@deessejs/errors'; +```ts title="causes.ts" +import { error, causes } from "@deessejs/errors"; -const AppError = error({ name: 'AppError' }); +const AppError = error({ name: "AppError" }); const appErr = AppError({}); -appErr.from(new Error('Original error')); +appErr.from(new Error("Original error")); const chain = causes(appErr); -console.log(chain.length); // 1 +console.log(chain.length); +``` + + +--- + +## ArgsValidationError + +Thrown when input data fails a Standard Schema validator attached to fields. The error carries the original validator failure information, branded as an @deessejs/errors instance. + +```ts title="args-validation.ts" +class ArgsValidationError extends Error +``` + +### Constructor + +| Parameter | Type | Description | +|-----------|------|-------------| +| source | string | Name of the originating factory | +| issues | ReadonlyArray<StandardSchemaV1.Issue> | Read-only array of validator issues (per Standard Schema spec) | +| vendor | string | Vendor identifier (e.g. zod, valibot, arktype) | + +### Instance properties + +| Property | Type | Description | +|----------|------|-------------| +| source | string | The originating factory name | +| issues | ReadonlyArray<StandardSchemaV1.Issue> | Validator issues | +| vendor | string | Vendor identifier | + +### Message format + +``` +Argument validation failed for "": see .issues +``` + +### Example + +```ts title="args-validation.ts" +import { z } from "zod"; +import { error, ArgsValidationError } from "@deessejs/errors"; + +const ValidationError = error({ + name: "ValidationError", + fields: z.object({ field: z.string().min(1) }), + message: (data) => "Field " + data.field, +}); + +try { + ValidationError({ field: "" }); +} catch (err) { + if (err instanceof ArgsValidationError) { + console.log(err.vendor); + console.log(err.issues); + } +} ``` --- ## ErrorInstance -The type of object returned by error factories. It extends the native `Error` type. +The type of object returned by error factories. It extends the native Error type. ### Properties | Property | Type | Description | |----------|------|-------------| -| `name` | `string` | Error name identifier | -| `message` | `string` | Human-readable error message | -| `stack` | `string` | Stack trace string | -| `fields` | `T` | User-defined fields | -| `notes` | `string[]` | Additional notes | -| `cause` | `Error \| null` | Direct cause of this error | -| `causes` | `Error[]` | Full cause chain | -| `context` | `Record \| null` | Injected context data | -| `inherits` | `ErrorFactory \| ErrorFactory[] \| undefined` | Parent error factories | +| name | string | Error name identifier | +| message | string | Human-readable error message | +| stack | string | Stack trace string | +| fields | T | Validated fields | +| notes | string[] | Additional notes (PEP 678) | +| cause | Error or null | Direct cause of this error | +| causes | Error[] | Full cause chain | +| context | Record<string, unknown> or null | Injected context data | +| inherits | ErrorFactory or ErrorFactory[] or undefined | Parent error factories | ### Methods | Method | Description | |--------|-------------| -| `from(cause: Error)` | Chains a cause error to this error | +| 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. | --- @@ -188,4 +253,7 @@ The type of object returned by error factories. It extends the native `Error` ty Chain errors with from(). - \ No newline at end of file + + Field validation with Standard Schema. + + diff --git a/apps/web/content/docs/error-factory.mdx b/apps/web/content/docs/error-factory.mdx index b05aa1a..db6e253 100644 --- a/apps/web/content/docs/error-factory.mdx +++ b/apps/web/content/docs/error-factory.mdx @@ -10,10 +10,10 @@ The `error()` function is the primary way to define error types in @deessejs/err At minimum, an error factory requires a name. This name identifies the error type and appears in the `name` property of instances. ```ts title="basic.ts" -import { error } from '@deessejs/errors'; +import { error } from "@deessejs/errors"; const NotFoundError = error({ - name: 'NotFoundError', + name: "NotFoundError", }); const err = NotFoundError({}); @@ -21,102 +21,107 @@ console.log(err.name); // "NotFoundError" console.log(err.message); // "NotFoundError" ``` -When you provide only a name, the message defaults to the same value. This is useful for simple errors where the name itself is descriptive enough. +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 that gives more context about what went wrong: +You can provide a custom message function that takes the validated fields and returns the final string: ```ts title="custom-message.ts" +import { error } from "@deessejs/errors"; + const ValidationError = error({ - name: 'ValidationError', - message: 'Validation failed', + name: "ValidationError", + 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 define the type of fields your error accepts using a generic parameter: +The real power of error factories comes from attaching structured data. You can declare the fields type using a generic parameter: ```ts title="with-fields.ts" const UserError = error<{ userId: string; reason: string }>({ - name: 'UserError', + name: "UserError", }); -const err = UserError({ userId: 'usr_123', reason: 'not found' }); +const err = UserError({ userId: "usr_123", reason: "not found" }); console.log(err.fields.userId); // "usr_123" console.log(err.fields.reason); // "not found" ``` -The fields object is completely flexible. You can include any data that helps describe the error: IDs, timestamps, values that caused the error, or any other context. +The fields object is fully accessible on the instance — useful for logging, error monitoring, and tooling. ## Using Fields with Schema Validation -For more robust error definitions, you can provide a Standard Schema (compatible with Zod, Valibot, or ArkType) to validate fields at creation time: +For robust error definitions, attach a [Standard Schema](https://standardschema.dev/) validator (Zod, Valibot, ArkType, etc.). Inputs are validated at creation time and invalid inputs throw an `ArgsValidationError`: ```ts title="with-schema.ts" -import { z } from 'zod'; -import { error } from '@deessejs/errors'; +import { z } from "zod"; +import { error } from "@deessejs/errors"; const ValidationError = error({ - name: 'ValidationError', + name: "ValidationError", fields: z.object({ field: z.string(), reason: z.string(), }), + message: (data) => `Field "${data.field}" is invalid: ${data.reason}`, }); -const err = ValidationError({ field: 'email', reason: 'invalid format' }); +const err = ValidationError({ field: "email", reason: "invalid format" }); +console.log(err.message); // 'Field "email" is invalid: invalid format' ``` -When fields don't match the schema, an error is thrown during error creation. This helps catch configuration mistakes early. +See [Fields and Schema](/docs/fields-schema) for the full breakdown, including vendor-neutral coverage of Zod, Valibot, and ArkType. ## Factory Properties The function returned by `error()` has several useful properties attached to it: ```ts title="properties.ts" -const AppError = error({ name: 'AppError' }); +const AppError = error({ name: "AppError" }); console.log(AppError.name); // "AppError" -console.log(AppError.schema); // undefined (no schema defined) console.log(AppError.inherits); // undefined (no parent) ``` -These properties are useful for introspection and for the `is()` function to check inheritance relationships. +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. ## Reusing Error Factories -Error factories are designed to be created once and reused throughout your application. Define them at module level so they're available everywhere: +Error factories are designed to be created once and reused throughout your application. Define them at module level so they are available everywhere: ```ts title="definitions.ts" -// errors/index.ts -import { error } from '@deessejs/errors'; +import { error } from "@deessejs/errors"; export const NotFoundError = error({ - name: 'NotFoundError', - message: 'Resource not found', + name: "NotFoundError", + message: () => "Resource not found", }); export const ValidationError = error({ - name: 'ValidationError', - message: 'Validation failed', + name: "ValidationError", + fields: z.object({ field: z.string(), reason: z.string() }), + message: (data) => `Field ${data.field}: ${data.reason}`, }); export const NetworkError = error({ - name: 'NetworkError', - message: 'Network request failed', + name: "NetworkError", + message: () => "Network request failed", }); ``` Then import and use them wherever needed: ```ts title="usage.ts" -import { raise } from '@deessejs/errors'; -import { NotFoundError } from './errors'; +import { raise } from "@deessejs/errors"; +import { NotFoundError } from "./errors"; if (!resource) { raise(NotFoundError({})); @@ -135,4 +140,7 @@ if (!resource) { Learn more about field definitions and schema validation. - \ No newline at end of file + + Full surface of error() and ArgsValidationError. + + diff --git a/apps/web/content/docs/fields-schema.mdx b/apps/web/content/docs/fields-schema.mdx index d29fc0b..1d67321 100644 --- a/apps/web/content/docs/fields-schema.mdx +++ b/apps/web/content/docs/fields-schema.mdx @@ -3,39 +3,37 @@ title: Fields and Schema description: Define structured fields and validation schemas for error types in @deessejs/errors. --- -Errors become much more useful when they carry structured data about what went wrong. @deessejs/errors lets you define fields on your error factories and optionally validate them using Standard Schema compatible libraries. +Errors become much more useful when they carry structured data about what went wrong. @deessejs/errors lets you define fields on your error factories and optionally validate them using any Standard Schema compatible library. ## Defining Fields When you create an error factory, you can specify a generic type that defines the shape of the fields object: ```ts title="basic.ts" -import { error } from '@deessejs/errors'; +import { error } from "@deessejs/errors"; const ValidationError = error<{ field: string; reason: string; value?: unknown; }>({ - name: 'ValidationError', - message: 'Validation failed', + name: "ValidationError", + message: "Validation failed", }); ``` Now when you create an error instance, TypeScript ensures you provide the required fields: ```ts title="usage.ts" -// All required fields provided const err = ValidationError({ - field: 'email', - reason: 'invalid format', + field: "email", + reason: "invalid format", }); -// Optional field omitted (valid) const err2 = ValidationError({ - field: 'email', - reason: 'invalid format', - value: 'not-an-email', + field: "email", + reason: "invalid format", + value: "not-an-email", }); ``` @@ -46,85 +44,93 @@ 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 } from '@deessejs/errors'; -import { raise } from '@deessejs/errors'; +import { error, raise } from "@deessejs/errors"; const ValidationError = error<{ field: string; reason: string }>({ - name: 'ValidationError', - message: 'Validation failed', + name: "ValidationError", + message: "Validation failed", }); try { - raise(ValidationError({ field: 'email', reason: 'not a valid email address' })); + raise(ValidationError({ field: "email", reason: "not a valid email address" })); } catch (err) { - // TypeScript knows the shape of fields - const fields = (err as { fields: { field: string; reason: string } }).fields; - console.log(`Error in field "${fields.field}": ${fields.reason}`); - // Output: Error in field "email": not a valid email address + // 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}`); + } } ``` -For full type inference in catch blocks, use the `is()` function with type narrowing. +For full type inference in catch blocks, use the [`is()`](/docs/type-checking) function with type narrowing. -## Schema Validation +## Runtime Validation via Standard Schema -For production applications, you may want to validate field values when errors are created. @deessejs/errors supports Standard Schema, which means you can use any compatible validation library like Zod, Valibot, or ArkType. +For production-grade error definitions, attach a [Standard Schema](https://standardschema.dev/) validator to `fields`. Any conforming library works — Zod, Valibot, ArkType, and any future implementation. -### Using Zod +Inputs that don't validate throw an `ArgsValidationError` carrying the vendor name and the failure issues: -```ts title="zod.ts" -import { z } from 'zod'; -import { error } from '@deessejs/errors'; +```ts title="runtime-validation.ts" +import { z } from "zod"; +import { ArgsValidationError, error } from "@deessejs/errors"; const ValidationError = error({ - name: 'ValidationError', + name: "ValidationError", fields: z.object({ field: z.string().min(1), reason: z.string().min(1), }), + message: (data) => `Field "${data.field}": ${data.reason}`, }); -// Valid error creation -const err = ValidationError({ field: 'email', reason: 'invalid' }); +// Valid input -> rendered message +const ok = ValidationError({ field: "email", reason: "invalid" }); +console.log(ok.message); // 'Field "email": invalid' -// Invalid creation - throws ZodError +// Invalid input -> ArgsValidationError try { - ValidationError({ field: '', reason: 'invalid' }); -} catch (e) { - console.log(e instanceof Error); // true + ValidationError({ field: "", reason: "invalid" }); +} catch (err) { + if (err instanceof ArgsValidationError) { + console.log(err.vendor); // "zod" + console.log(err.issues); // StandardSchemaV1.FailureResult['issues'] + } } ``` ### Using Valibot ```ts title="valibot.ts" -import { valibot } from 'fumadocs-core/source'; -import { error } from '@deessejs/errors'; +import * as v from "valibot"; +import { error } from "@deessejs/errors"; const ValidationError = error({ - name: 'ValidationError', - fields: valibot({ - field: 'string', - reason: 'string', + name: "ValidationError", + fields: v.object({ + field: v.pipe(v.string(), v.minLength(1)), + reason: v.pipe(v.string(), v.minLength(1)), }), + message: (data) => `Field ${data.field}: ${data.reason}`, }); ``` ### Using ArkType ```ts title="arktype.ts" -import { error } from '@deessejs/errors'; -import { t } from 'arktype'; +import { type } from "@ark/type"; +import { error } from "@deessejs/errors"; const ValidationError = error({ - name: 'ValidationError', - fields: t.type({ - field: 'string', - reason: 'string', + name: "ValidationError", + fields: type({ + field: "string", + reason: "string", }), + message: (data) => `Field ${data.field}: ${data.reason}`, }); ``` +> 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. + ## Why Use Schema Validation? Schema validation in error factories provides several benefits: @@ -135,32 +141,31 @@ Schema validation in error factories provides several benefits: **Consistent data** — All errors of a given type have the same structure, making logging and monitoring easier. +**Vendor-neutral** — Switch between Zod, Valibot, ArkType, or any future Standard Schema implementation without changing your error definitions. + ## Common Field Patterns Here are some common patterns for error fields: ```ts title="patterns.ts" -// Database errors const DatabaseError = error<{ query: string; table?: string; code?: string; -}>({ name: 'DatabaseError' }); +}>({ name: "DatabaseError" }); -// API errors const ApiError = error<{ endpoint: string; statusCode: number; response?: unknown; -}>({ name: 'ApiError' }); +}>({ name: "ApiError" }); -// Validation errors const ValidationError = error<{ field: string; reason: string; value?: unknown; constraints?: Record; -}>({ name: 'ValidationError' }); +}>({ name: "ValidationError" }); ``` Design your fields to capture what's useful for debugging and logging, not just what's required to identify the error. @@ -171,7 +176,10 @@ Design your fields to capture what's useful for debugging and logging, not just Create error types with the error() function. + + Full surface of `error()` and `ArgsValidationError`. + See practical examples of error definitions for common scenarios. - \ 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 d889002..fe540b3 100644 --- a/apps/web/content/docs/message-templates.mdx +++ b/apps/web/content/docs/message-templates.mdx @@ -1,162 +1,118 @@ --- -title: Message Templates -description: Use message templates with field placeholders and modifiers in @deessejs/errors. +title: Message Rendering +description: Render error messages from validated fields in @deessejs/errors. --- -Message templates let you create dynamic error messages that include runtime values. This makes errors more informative and easier to debug, as the message contains specific context about what went wrong. +When you attach a `fields` schema to an error factory, the message is computed from the **parsed** data, not the raw input. This lets you format fields that have been transformed, validated, or branded by your schema. -## Basic Placeholders +## Function form (recommended) -Wrap field names in curly braces to create placeholders in your message: +Pass a function that takes the parsed fields and returns the message. The data you receive is the Standard Schema output, with all transforms already applied: -```ts title="basic.ts" -import { error } from '@deessejs/errors'; +```ts title="function.ts" +import { z } from "zod"; +import { error } from "@deessejs/errors"; -const ValidationError = error<{ field: string }>({ - name: 'ValidationError', - message: 'Field "{field}" is invalid', +const ValidationError = error({ + name: "ValidationError", + fields: z.object({ + field: z.string(), + reason: z.string(), + }), + message: (data) => `Field "${data.field}" is invalid: ${data.reason}`, }); -const err = ValidationError({ field: 'email' }); -console.log(err.message); // "Field "email" is invalid" +const err = ValidationError({ field: "email", reason: "invalid format" }); +console.log(err.message); // 'Field "email" is invalid: invalid format' ``` -The placeholder is replaced with the actual value from the fields object at runtime. +The function receives the **Standard Schema output** type. After a `z.coerce.number()`, `z.brand()`, or any transform, you see the final shape. -## Multiple Placeholders +### With Valibot -You can include multiple placeholders in a single message: +```ts title="valibot.ts" +import * as v from "valibot"; +import { error } from "@deessejs/errors"; -```ts title="multiple.ts" -import { error } from '@deessejs/errors'; - -const FormatError = error<{ expected: string; actual: string }>({ - name: 'FormatError', - message: 'Expected {expected}, got {actual}', +const FormatError = error({ + name: "FormatError", + fields: v.object({ + expected: v.string(), + actual: v.string(), + }), + message: (data) => `Expected ${data.expected}, got ${data.actual}`, }); -const err = FormatError({ expected: 'number', actual: 'string' }); +const err = FormatError({ expected: "number", actual: "string" }); console.log(err.message); // "Expected number, got string" ``` -Each placeholder is replaced with its corresponding field value. - -## Modifiers - -Modifiers transform the placeholder value before insertion. They follow the field name with a colon. - -### Uppercase Modifier +### With ArkType -The `:upper` modifier converts the value to uppercase: +```ts title="arktype.ts" +import { type } from "@ark/type"; +import { error } from "@deessejs/errors"; -```ts title="upper.ts" -import { error } from '@deessejs/errors'; - -const UserCreatedError = error<{ userId: string }>({ - name: 'UserCreatedError', - message: 'Created user: {userId:upper}', +const ApiError = error({ + name: "ApiError", + fields: type({ + method: "string", + endpoint: "string", + statusCode: "number", + }), + message: (data) => `${data.method.toUpperCase()} ${data.endpoint} failed with status ${data.statusCode}`, }); - -const err = UserCreatedError({ userId: 'usr_abc123' }); -console.log(err.message); // "Created user: USR_ABC123" ``` -This is useful for displaying IDs or codes that should be consistently formatted. - -### Lowercase Modifier +### Modifiers via the function body -The `:lower` modifier converts the value to lowercase: +The function form replaces the legacy `:upper` / `:lower` / `:json` modifiers. Implement them inline with the JS string APIs you already know: -```ts title="lower.ts" -import { error } from '@deessejs/errors'; - -const PathError = error<{ path: string }>({ - name: 'PathError', - message: 'Invalid path: {path:lower}', +```ts title="modifiers.ts" +const err = error({ + name: "UserCreatedError", + fields: z.object({ userId: z.string() }), + message: (data) => `Created user: ${data.userId.toUpperCase()}`, }); - -const err = PathError({ path: '/USERS/DATA' }); -console.log(err.message); // "Invalid path: /users/data" ``` -### JSON Modifier - -The `:json` modifier serializes complex values to JSON: +`JSON.stringify` replaces `:json`: ```ts title="json.ts" -import { error } from '@deessejs/errors'; - -const DataError = error<{ data: { id: number; name: string } }>({ - name: 'DataError', - message: 'Invalid data: {data:json}', -}); - -const err = DataError({ data: { id: 1, name: 'test' } }); -console.log(err.message); // "Invalid data: {"id":1,"name":"test"}" -``` - -The JSON modifier is particularly useful for debugging when you need to see the full value. - -## Combining Modifiers - -Modifiers can be combined with other fields: - -```ts title="combined.ts" -import { error } from '@deessejs/errors'; - -const ApiError = error<{ - method: string; - endpoint: string; - statusCode: number; -}>({ - name: 'ApiError', - message: '{method:upper} {endpoint} failed with status {statusCode}', +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 = ApiError({ - method: 'get', - endpoint: '/api/users', - statusCode: 404, -}); -console.log(err.message); // "GET /api/users failed with status 404" ``` -## Missing Field Values - -If a placeholder references a field that wasn't provided, the placeholder is left unchanged in the message: +## Legacy: string template -```ts title="missing.ts" -import { error } from '@deessejs/errors'; +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. -const ValidationError = error<{ field: string }>({ - name: 'ValidationError', - message: 'Field "{field}" is required', +```ts title="legacy.ts" +const ValidationError = error({ + name: "ValidationError", + message: "Field {field} is invalid", }); -const err = ValidationError({}); // field not provided -console.log(err.message); // "Field "{field}" is required" +const err = ValidationError({ field: "email" }); +console.log(err.message); // "Field email is invalid" ``` -This behavior is intentional — it helps you identify when expected data is missing. - -## Best Practices +Behavior summary: -**Be specific** — Include enough context in the message to understand the error without looking at the code. - -**Keep messages readable** — While placeholders add information, don't overload the message with too many values. - -**Use modifiers appropriately** — Uppercase for codes and IDs, lowercase for paths, JSON for complex objects. +- 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`. ## See Also - - Create error types with the error() function. - - Define structured fields for errors. + Define fields with Standard Schema validators. - - Practical examples of error definitions. + + Full signature of `error()` and `ArgsValidationError`. - \ No newline at end of file + diff --git a/docs/internal/engineering/rfcs/0001-standard-schema-fields.md b/docs/internal/engineering/rfcs/0001-standard-schema-fields.md new file mode 100644 index 0000000..221e89a --- /dev/null +++ b/docs/internal/engineering/rfcs/0001-standard-schema-fields.md @@ -0,0 +1,319 @@ +# RFC 0001 — Promote `StandardSchemaV1` to runtime validation + message-as-function + +- **Status:** Draft +- **Author:** martyy-code (via Claude session) +- **Created:** 2026-08-05 +- **Supersedes / relates to:** Issue #32 +- **Target version:** `@deessejs/errors@1.4.0` (first minor that contains the new API), removal of legacy in `2.0.0` +- **Depends on:** nothing blocking; unblocks [issue #33](../..) (typed `ErrorGroup`) + +## Summary + +Promote the existing type-level `StandardSchemaV1` field to runtime validation, and replace the implicit `{placeholder}` template with an explicit `(args) => string` function. The output schema is the source of truth for both types and runtime checks. The legacy template form is kept for one minor release, then removed. + +This RFC is the design discussion that precedes any code change. Decisions taken here are inputs to the implementation PR. + +## 1. Motivation + +The current `error({...})` factory accepts an implicit, type-free argument object whose shape is conveyed only by placeholders inside a template string. The package already declares `StandardSchemaV1` on both `ErrorFactory.schema` and `ErrorConfig.fields`, but the runtime **never validates** against it. The field exists in the type system and is silently ignored at runtime. Anyone calling `MyError()` with an empty object gets a string of literal `{name}`, with no error raised. + +Issue #32 describes the end state and the migration path. The job of this RFC is to **lock the design decisions** before any code lands, because each decision changes the public surface in ways that are not undoable inside a minor release. + +### 1.1 Goals + +- Inputs to the factory are validated at instantiation against a user-supplied `StandardSchemaV1`. +- The TypeScript signature infers the args type from the schema, with no manual generic argument. +- The legacy `message: string` template form keeps working for one minor release with a deprecation warning. +- The package remains vendor-neutral — zod, valibot, arktype, and any future Standard Schema implementation work without changes to `@deessejs/errors` itself. +- The codemod that migrates legacy templates is published **separately from** the main npm package. + +### 1.2 Non-goals + +- No change to the `.from(cause)`, `.addNote()`, `raise()`, `is()`, or `causes()` APIs. +- No change to the `inherits` model. +- No new `ErrorGroup` API in this release — it lands in a follow-up issue (#33) that depends on this RFC being merged and the new API being shipped. + +## 2. Current state (1.3.3 baseline) + +The relevant code lives in `packages/errors/src/error/`: + +- `types.ts` declares: + + ```ts + fields?: StandardSchemaV1; + schema?: StandardSchemaV1; + ``` + + Both exist. Neither has a documented role. The duplicate declaration predates the v1.0 release and was never resolved. + +- `error.ts` (the `error()` factory): + + ```ts + export const error = >(config: { ... }): ErrorFactory => { + const ErrorFactoryInstance = (input?: Partial): ErrorInstance => { + const fieldsData = (input || {}) as T; + let errorMessage = name; + if (message && hasTemplatePlaceholders(message)) { + errorMessage = formatTemplate(message, fieldsData); + } else if (message) { + errorMessage = message; + } + ... + }; + }; + ``` + + No call to `~standard-schema/validate`. The `input` is cast to `T` without runtime checks. The template engine is invoked only when `message` is a string and contains `{...}` placeholders. + +- `format.ts` contains `formatTemplate` and `hasTemplatePlaceholders`. Both are string-only utilities. + +- Tests in `packages/errors/tests/error.test.ts` exercise the template path heavily and the schema path not at all. + +### 2.1 What is actually working + +- The legacy template form is reliable, tested, and documented. +- The `@standard-schema/spec` package is already a runtime dependency in `packages/errors/package.json`. +- `StandardSchemaV1` is exposed in public exports from `packages/errors/src/index.ts`. + +### 2.2 What is not working + +- `StandardSchemaV1` is declared but never invoked. +- `ErrorConfig.fields?` and `ErrorFactory.schema?` are duplicates with no documented distinction. +- The error message is a string interpolation, which makes i18n, pluralization, and conditional formatting land at the call site. +- A consumer cannot import a type for `err.fields`; there is nothing to import. + +## 3. Open design decisions + +The following decisions must be locked before any code lands. + +### Decision A — Where to declare the schema + +Two choices, both currently in the source: + +| Option | Surface | Pros | Cons | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `fields` (in `ErrorConfig`) | Already exported. Single source of truth: the schema lives where it is _declared_, alongside `name` and `message`. | The schema is part of the error _definition_. Consumers can read it back via `MyError.schema` to build UIs, serializers, etc. | Increases coupling between `ErrorConfig` and the validator type. | +| `schema` (in `ErrorFactory`) | Already exported. Stored on the _factory_ post-construction. | Easier to add later (e.g. via a separate `withSchema(s)` builder). | Splits the schema declaration from the rest of the config. Two fields doing the same job is confusing. | + +**Recommendation: keep `fields` on `ErrorConfig`. Remove `schema` on `ErrorFactory`.** This is the breaking change, but the `fields` location matches the natural reading: `error({ name, fields, message })`. `schema` was added speculatively and has no consumer. + +### Decision B — `message: string` vs `message: (args) => string` + +The proposal in #32 is to **require** the function form and deprecate the string form. Two refinements to that proposal are needed: + +1. The function must receive the **parsed** (post-transform) args, not the raw input, so it can rely on `.brand()`, `.refine()`, `.transform()` behaviour. +2. The function must receive the **Standard Schema result** directly so users that want raw input can pass it through unmodified. Concretely: + ```ts + message: (data) => string; + ``` + where `data` is the schema output type. No `{placeholder}` interpolation occurs when `message` is a function. + +**Recommendation: function form only on the new API.** Reject `message: string` when `fields` is supplied. The legacy string-only path (without `fields`) keeps working. + +### Decision C — Generic signature of `error()` + +Three options: + +| Option | Signature | Notes | +| ------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| C1. Manually typed | `>(...)` (current) | Loses inference from the schema. Requires the user to repeat types. | +| C2. Schema-inferred | `>>(...)` | Infers `Output` from the schema. Requires `S` to satisfy the constraint, which valibot / arktype variants may not. | +| C3. Overloaded | One overload for schema mode, one for manual mode | Maximum flexibility; double the test surface; harder to document. | + +**Recommendation: C2.** The constraint is small enough to express correctly, and the inference win is real. If a validator does not satisfy the constraint, the consumer can fall back to C1 in the same release. + +### Decision D — Runtime failure shape + +`StandardSchemaV1.result.issues` is the standard way to surface a validation failure. The library can either: + +- **Re-throw** the validator's raw failure object (simplest, not library-branded) +- **Wrap** in a new `ArgsValidationError` class that includes the issues + +**Recommendation: wrap in `ArgsValidationError`.** It carries a `.issues` getter for the raw data and is itself a valid `ErrorFactory` output. Consistent with the rest of the API. Allows consumers to catch the failure with `try/catch` and a single type guard. + +### Decision E — Deprecation window + +The legacy string-template path needs a deprecation marker. Two strategies: + +- **Runtime warning** printed once when `fields` is missing and `message` is a string. Cheap to add, intrusive. +- **Compile-time hint** via a `@deprecated` JSDoc tag. Quieter, no runtime cost. Only catches consumers reading the docs. + +**Recommendation: both.** Add `@deprecated` to the relevant type branches and a `console.warn` printed at most once per call site via a `WeakSet`. Disable both via `process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES=1` if the user wants a clean run. + +### Decision F — Codemod placement + +The proposal places the codemod at `packages/errors/codemods/`. This is wrong: + +- It would publish the codemod's `package.json` and entry point with the main npm package, polluting the consumer surface and the bundle. +- A codemod is a **tool**, not a library. Consumers do not `import` it; they `npx` it. + +**Recommendation: separate sub-package at `packages/errors-codemods/`** with its own `package.json`, its own version cadence, and a single `bin` entry. Document the codemod in this RFC's migration plan but do not implement it inside this RFC. Implementation is a follow-up. + +## 4. Proposed API + +The final shape that every consumer will write in this release cycle: + +```ts +import { z } from 'zod'; +import { error } from '@deessejs/errors'; + +const ValidationError = error({ + name: 'ValidationError', + fields: z.object({ + field: z.string(), + reason: z.string(), + code: z.enum(['invalid', 'missing', 'too_long']), + }), + message: (args) => `Field "${args.field}" is invalid (${args.code}): ${args.reason}`, +}); +``` + +Same builder, different validator: + +```ts +import * as v from 'valibot'; + +const ValidationError = error({ + name: 'ValidationError', + fields: v.object({ + field: v.string(), + reason: v.string(), + code: v.picklist(['invalid', 'missing', 'too_long']), + }), + message: (args) => `Field "${args.field}" is invalid (${args.code}): ${args.reason}`, +}); +``` + +The legacy form keeps working but emits a deprecation warning: + +```ts +const LegacyValidationError = error({ + name: 'ValidationError', + message: 'Field "{field}" is invalid: {reason}', +}); + +// still works in 1.4.x, removed in 2.0.0 +``` + +Public type changes: + +- `ErrorConfig` (renamed; old `ErrorConfig` shape deprecated) +- `ErrorFactory` +- `ErrorInstance` +- New `ArgsValidationError` factory exported +- `StandardSchemaV1` already exported +- `error>` overload + +## 5. Implementation plan + +Five work items, ordered. Each is its own PR where it makes sense. + +### PR 1 — RFC + +This document. Reviewer: maintainer only. Merge directly to `main`. + +### PR 2 — New API surface (1.4.0-beta) + +- Add the new `error()` overload that accepts `fields: StandardSchemaV1` and `message: (data) => string`. +- Add `ArgsValidationError` factory. +- Keep the old template path as a fallback when `fields` is not supplied. +- Add deprecation warnings on the old path. +- Tests across zod, valibot, arktype. + +### PR 3 — Deprecation documentation and migration guide + +- README: side-by-side zod / valibot / arktype examples. +- `releasing-a-new-version.md` and this RFC's appendices: migration cookbook. +- `CHANGELOG` via changeset (`minor` because of new API). + +### PR 4 — Built-in error tree migration (optional) + +Apply the new API to the package's own error factories (if any — currently the package does not ship built-in error factories). Document this PR as a "we migrated our own code" reference implementation. + +### PR 5 — Removal in 2.0.0 + +Major version bump. Delete the legacy template path and the deprecation warning. Remove `process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES` from docs. + +> The previously planned PR 4 (codemod) was removed during review by decision Q3. The migration is now manual via the guide added in PR 3. If a future release shows that the manual migration is too friction-laden, a codemod PR can be reintroduced. + +## 6. Risks revisited + +The original issue lists six risks. The ones this RFC explicitly **increases**: + +- **Inference complexity.** Decision C2 (`StandardSchemaV1` constrained input) is known to occasionally blow up `tsc` on highly generic schemas. Mitigation: a `tests/types/` directory with `expectTypeOf` from vitest, run in CI. Block the release if inference exceeds 5s on a representative consumer example. +- **Validator behaviour divergence.** Different validators return different `Output` for the same `Input`. Mitigation: trust the standard. Document in the RFC that consumers using raw validator output should consult their validator's docs. +- **Silent changes in `err.fields`.** Old code returned `(input || {}) as T`. New code returns `result.value` from Standard Schema. Mitigation: `PackageMigration.test.ts` snapshot-diff the existing test suite, flag breaking changes, update tests. + +The ones the issue already handles correctly: + +- **Breaking change.** Handled by the deprecation window (manual migration; no codemod by decision Q3). +- **Standard Schema version drift.** Handled by the `^1.0.0` pin in `package.json`. +- **New validation behavior surface.** Handled by the deprecation window and the documented `ArgsValidationError`. + +## 7. Out of scope + +Everything the issue lists as `Out of scope` stays out: + +- Inheritance model changes. +- New `.addNote()` work (already shipped in 1.3.0). +- `.from(cause)` changes. +- Typed `ErrorGroup` (issue #33, depends on this). + +## 8. Decisions locked by this RFC + +| # | Decision | Choice | +| --- | --------------------------------------------------------------------------- | ------------------------ | +| A | Schema declared in `ErrorConfig.fields`, removed from `ErrorFactory.schema` | `fields` only | +| B | `message` accepts function; template stays as legacy fallback | both, function preferred | +| C | `error()` generic parameter inferred from `StandardSchemaV1` | C2 | +| D | Validation failure wrapped in `ArgsValidationError` | yes | +| E | Deprecation marker is both JSDoc + runtime warning | both | +| F | No codemod for this release (per Q3); migration is manual via the guide | none | + +These are the inputs to any PR against the source. If a reviewer disagrees with one of them, that decision is unlocked here, not in code review. + +## 9. Decisions locked by review + +The following Q&A happened during review of this RFC. They are now part of the contract. + +### Q1 — Alias de dépréciation pour `ErrorFactory.schema` + +**Tranchée :** Cassure unique en 1.4.0. + +`ErrorFactory.schema` est supprimé dans la 1.4.0 sans alias `@deprecated` ni période de grâce. Aucun consumer connu dans le repo, dans la doc, ou dans le CHANGELOG. Préserver le doublon pendant deux versions minerait l'intention du refactor. Le search-and-replace `MyError.schema → MyError.fields` est trivial et documenté dans la migration guide. + +### Q2 — Emplacement de `ArgsValidationError` + +**Tranchée :** Top-level depuis `@deessejs/errors`. + +`import { ArgsValidationError } from '@deessejs/errors'`. Cohérent avec l'organisation actuelle où `error`, `raise`, `is`, `causes`, et `StandardSchemaV1` sont déjà exportés depuis `src/index.ts`. Le sub-path `@deessejs/errors/validation` reste une migration future possible sans casser les imports top-level existants. + +### Q3 — Codemod de migration + +**Tranchée :** Aucun codemod pour cette release. + +Le paquet `@deessejs/errors` n'a pas de consumer users-spécifiques connu. La migration vers la nouvelle API se fait par guide dans le CHANGELOG et la doc — search-and-replace manuel, validation Standard Schema à choisir librement par le consumer. Si la friction s'avère trop forte sur des cas réels, un codemod pourra être ajouté dans une release ultérieure, soit per-package (`@deessejs/errors-codemods`) soit org-wide (`@deessejs/codemods`) selon les besoins du moment. + +Conséquence : PR 4 (codemod) de la section 5 est supprimé du plan d'implémentation. + +### Q4 — Quand supprimer `ErrorFactory.schema` ? + +**Tranchée :** En 1.4.0, en même temps que la nouvelle API. + +Cohérent avec Q1. Étaler la cassure sur deux minors multiplierait les cycles de release sans bénéfice — le champ `schema` n'est de toute façon utilisé par personne. Étape unique : supprimer `schema` lors du même commit qui introduit la nouvelle API. + +## 10. References + +- [Issue #32](https://github.com/deessejs/errors/issues/32) — original proposal +- [Issue #33](https://github.com/deessejs/errors/issues/33) — `ErrorGroup`, depends on this RFC +- [Standard Schema spec](https://github.com/standard-schema/standard-schema) +- `packages/errors/src/error/types.ts` — current public types +- `packages/errors/src/error/error.ts` — current factory +- `packages/errors/tests/error.test.ts` — current test surface (template path only) +- `DESIGN.md` (root) — current design notes + +## Changelog + +| Date | Author | Change | +| ---------- | -------------------------------- | ------------------------------------- | +| 2026-08-05 | martyy-code (via Claude session) | Initial draft, derived from issue #32 | diff --git a/docs/internal/engineering/rfcs/README.md b/docs/internal/engineering/rfcs/README.md new file mode 100644 index 0000000..e988eed --- /dev/null +++ b/docs/internal/engineering/rfcs/README.md @@ -0,0 +1,43 @@ +# Engineering RFCs + +This directory holds Request For Comments documents for substantive engineering changes to the `@deessejs/errors` package or the surrounding tooling. RFCs are how we lock design decisions before any code lands. + +## When to write an RFC + +Write an RFC when: + +- The change touches a public API (exports, types, runtime behavior of a callable). +- Multiple valid design paths exist that trade off against each other. +- The change will land across more than one PR or version. +- A future reader will want to know _why_ the code looks the way it does. + +## Lifecycle + +1. **Draft.** The author opens a PR adding the RFC. Reviewers push back. Comments are inline. +2. **Accepted.** The author addresses feedback, the maintainer merges the RFC into `main`. The RFC becomes part of the project record. +3. **Implemented.** Implementation PRs reference the RFC by number. Each implementation step is its own PR. +4. **Superseded.** A later RFC replaces this one. The old RFC points to the new one at the top. + +## Index + +| Number | Title | Status | Target version | +| ---------------------------------------- | ---------------------------------------------------------------------- | ------ | ------------------------ | +| [0001](./0001-standard-schema-fields.md) | Promote `StandardSchemaV1` to runtime validation + message-as-function | Draft | `@deessejs/errors@1.4.0` | + +## Format + +Each RFC file follows: + +1. Summary +2. Motivation / non-goals +3. Current state (with file references) +4. Open design decisions (the table at the top must be filled in) +5. Proposed API +6. Implementation plan (ordered PRs) +7. Risks +8. Out of scope +9. Decisions locked by this RFC +10. Open questions for the maintainer +11. References + +Numbers are assigned by the maintainer. Filenames use the format `NNNN-kebab-case-title.md`. diff --git a/packages/errors/package.json b/packages/errors/package.json index 3810072..f2ea812 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -61,12 +61,15 @@ "url": "https://github.com/sponsors/deessejs" }, "devDependencies": { + "@ark/type": "^2.2.3", "@eslint/js": "^9.0.0", "@types/node": "^25.9.1", "eslint": "^9.0.0", "typescript": "^6.0.3", "typescript-eslint": "^8.0.0", - "vitest": "^4.1.7" + "valibot": "^1.4.2", + "vitest": "^4.1.7", + "zod": "^4.4.3" }, "dependencies": { "@standard-schema/spec": "^1.1.0" diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index 9f420a8..c65d42f 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -10,6 +10,18 @@ import type { ErrorFactory, ErrorInstance } from './types.js'; import { captureStack } from './capture.js'; import { formatTemplate, hasTemplatePlaceholders } from './format.js'; +// ============================================================================ +// Node ambient types +// ============================================================================ + +// 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. +declare const process: + | { + env: Record; + } + | undefined; + // ============================================================================ // Symbols for identity // ============================================================================ @@ -22,18 +34,156 @@ import { formatTemplate, hasTemplatePlaceholders } from './format.js'; */ const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory'); +// ============================================================================ +// 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. + * + * Set `process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES = '1'` to silence. + * + * @internal + */ +const warnedLegacyCallSites = new Set(); +function warnLegacy(callSite: string): void { + const legacyGate = (process as { env?: Record } | undefined)?.env + ?.DEESSEJS_ERRORS_LEGACY_TEMPLATES; + if (legacyGate === '1') return; + if (warnedLegacyCallSites.has(callSite)) return; + warnedLegacyCallSites.add(callSite); + console.warn( + `[@deessejs/errors] Legacy string-template form in \`error({...})\` is deprecated and will be removed in 2.0.0. ` + + `Migrate to \`fields: standardSchema + message: (data) => string\`. ` + + `See https://github.com/deessejs/errors/blob/main/docs/internal/engineering/rfcs/0001-standard-schema-fields.md. ` + + `(Site: ${callSite})` + ); +} + +// ============================================================================ +// Validation +// ============================================================================ + +/** + * 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. + * + * @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; + if (result && typeof (result as Promise).then === 'function') { + throw new ArgsValidationError( + `Async schemas are not supported in \`error({...})\`. ` + + `Use \`schema\` directly (await) before instantiating.`, + [{ message: 'Async validation not supported in error()' }], + handle['~standard'].vendor ?? 'unknown' + ); + } + const r = result as { value?: unknown; issues?: unknown }; + if (r && Array.isArray(r.issues)) { + return { ok: false, issues: r.issues as ReadonlyArray }; + } + return { ok: true, value: r.value as unknown }; +} + +// ============================================================================ +// ArgsValidationError +// ============================================================================ + +/** + * 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`. + * + * @example + * ```ts + * import { error } from '@deessejs/errors'; + * import { z } from 'zod'; + * + * const ValidationError = error({ + * name: 'ValidationError', + * fields: z.object({ field: z.string() }), + * message: (data) => `Field "${data.field}" invalid`, + * }); + * + * try { + * 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 + * } + * } + * ``` + */ +export class ArgsValidationError extends Error { + /** The factory's `name` field, surfaced for logs and UIs. */ + public readonly source: string; + /** 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. + */ + 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)}`); + this.name = 'ArgsValidationError'; + this.source = source; + this.issues = issues; + this.vendor = vendor; + Object.setPrototypeOf(this, ArgsValidationError.prototype); + } +} + // ============================================================================ // Error Factory // ============================================================================ +/** + * Format the call-site string used in deprecation warnings. Inlined here + * (rather than importing `callsites`) to keep the bundle small. + * + * @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. * + * Two configurations are supported: + * + * **Standard path** (RFC 0001): pass `fields: standardSchema` and a + * function-form `message`. Args are validated at instantiation. + * + * **Legacy path** (deprecated in 1.4.0, removed in 2.0.0): pass a string + * `message`. No validation runs. + * * @param config - Error configuration - * @param config.name - Error name identifier - * @param config.fields - Standard Schema field definitions (Zod, Valibot, ArkType, etc.) - * @param config.inherits - Parent error factory to inherit from - * @param config.message - Message template with {field} placeholders * * @example * ```typescript @@ -45,54 +195,66 @@ const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory'); * field: z.string(), * reason: z.string(), * }), - * message: 'Field "{field}" is invalid: {reason}', + * message: (data) => `Field "${data.field}" is invalid: ${data.reason}`, * }); * * const err = ValidationError({ field: 'email', reason: 'invalid format' }); - * // err.message === 'Field "email" is invalid: invalid format' * ``` * * @example * ```typescript - * // Single inheritance - * const AppError = error({ name: 'AppError' }); - * const ValidationError = error({ - * name: 'ValidationError', - * inherits: AppError, - * }); - * ``` - * - * @example - * ```typescript - * // Multiple inheritance - * const NetworkError = error({ name: 'NetworkError' }); - * const StorageError = error({ name: 'StorageError' }); - * const CombinedError = error({ - * name: 'CombinedError', - * inherits: [NetworkError, StorageError], + * // Legacy string-template form (deprecated, removed in 2.0.0) + * const LegacyError = error({ + * name: 'LegacyError', + * message: 'Hello {name}', * }); * ``` */ -export const error = = Record>(config: { +export function error = Record>(config: { name: string; fields?: StandardSchemaV1; + message?: string | ((data: T) => string); inherits?: ErrorFactory | ErrorFactory[]; - message?: string; -}): ErrorFactory => { +}): ErrorFactory { const { name, fields, inherits, message } = config; + // 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'; + /** * Error factory function - creates error instances. */ - const ErrorFactoryInstance = (input?: Partial): ErrorInstance => { - const fieldsData = (input || {}) as T; - - // Format message if template has placeholders + const ErrorFactoryInstance: ErrorFactory = (input?: Partial): ErrorInstance => { + let fieldsData: Record = {}; let errorMessage = name; - if (message && hasTemplatePlaceholders(message)) { - errorMessage = formatTemplate(message, fieldsData); - } else if (message) { - errorMessage = message; + + 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'); + } + const result = runSchema(fields, input); + if (!result.ok) { + throw new ArgsValidationError( + name, + result.issues as ReadonlyArray, + fields['~standard'].vendor + ); + } + fieldsData = (result.value as Record) ?? {}; + errorMessage = (message as (data: T) => string)(fieldsData as unknown as T); + } else { + // Legacy path — coerce input and interpolate the template if any. + fieldsData = (input && typeof input === 'object' ? input : {}) as Record; + if (typeof message === 'string' && hasTemplatePlaceholders(message)) { + errorMessage = formatTemplate(message, fieldsData); + } else if (typeof message === 'string') { + errorMessage = message; + } + // 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 @@ -101,7 +263,7 @@ export const error = = Record; instance.name = name; - instance.fields = fieldsData; + instance.fields = fieldsData as unknown as T; instance.notes = []; instance.cause = null; instance.causes = []; @@ -126,7 +288,6 @@ export const error = = Record unknown>)[FACTORY_SYMBOL] = ErrorFactoryInstance; @@ -153,8 +314,8 @@ export const error = = Record).rawMessage = message; } - return ErrorFactoryInstance as ErrorFactory; -}; + return ErrorFactoryInstance; +} // ============================================================================ // Exports for is() function diff --git a/packages/errors/src/error/types.ts b/packages/errors/src/error/types.ts index 41ab0f6..0bf5aac 100644 --- a/packages/errors/src/error/types.ts +++ b/packages/errors/src/error/types.ts @@ -8,6 +8,21 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; // Types // ============================================================================ +/** + * Helper to extract the inferred output type from a `StandardSchemaV1`. + * + * Standard Schema declares `~standard.schema.` with input/output generics. + * Most validators (zod, valibot, arktype, etc.) infer `Output` from the schema + * builder. This helper simply walks the property path. + * + * @example + * ```ts + * type T = InferStandardSchemaOutput; + * // T === { id: string } + * ``` + */ +export type InferStandardSchemaOutput = S extends StandardSchemaV1 ? O : never; + /** * Core properties present on every error instance. * These are guaranteed to exist regardless of how the error was created. @@ -29,8 +44,16 @@ export type ErrorFactory = Record): ErrorInstance; name: string; inherits?: ErrorFactory | ErrorFactory[]; + /** + * The Standard Schema used to validate the args at instantiation time. + * Exposed for consumers that want to read it back from the factory itself. + */ schema?: StandardSchemaV1; - rawMessage?: string; + /** + * The original message template or function. Exposed for introspection + * (e.g. docs UI, serializer inspection). + */ + rawMessage?: string | ((data: TFields) => string); }; /** @@ -76,7 +99,6 @@ export type ErrorInstance = Record | null; /** Parent error factories for type checking */ @@ -84,17 +106,59 @@ export type ErrorInstance = Record = Record> = { +export type StandardErrorConfig< + S extends StandardSchemaV1, + M extends (data: InferStandardSchemaOutput) => string, +> = { /** Error name identifier */ name: string; - /** Standard Schema field definitions */ - fields?: StandardSchemaV1; + /** Standard Schema field definitions (zod, valibot, arktype, etc.) */ + fields: S; /** Single parent error factory to inherit from */ inherits?: ErrorFactory | ErrorFactory[]; - /** Message template with {field} placeholders */ + /** Message-as-function, receives the validated output */ + message: M; +}; + +/** + * Legacy config: no schema, plain string message template. + * + * Marked `@deprecated` in 1.4.0; removed in 2.0.0. + */ +export type LegacyErrorConfig = { + /** Error name identifier */ + name: string; + /** @deprecated Single parent error factory to inherit from */ + inherits?: ErrorFactory | ErrorFactory[]; + /** @deprecated Message template with `{field}` placeholders */ message?: string; + /** + * @deprecated Was never wired up to runtime validation. Migrate to + * `StandardErrorConfig` (RFC 0001). + */ + schema?: StandardSchemaV1; }; + +/** + * Configuration accepted by `error()`. + * + * - Standard path: supply `fields` (a `StandardSchemaV1`) and a function-form + * `message`. The args shape is inferred. + * - Legacy path: omit `fields` or use a string `message`. Works in 1.4.0 with + * a deprecation warning; removed in 2.0.0. + */ +export type ErrorConfig = + | StandardErrorConfig< + StandardSchemaV1, + (data: InferStandardSchemaOutput) => string + > + | LegacyErrorConfig; diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index c6aae91..c542078 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -11,6 +11,9 @@ export type { ErrorFactory, ErrorInstance, ErrorInstanceCore } from './error/typ // Error factory function export { error } from './error/error.js'; +// ArgsValidationError class (re-exported so consumers can instanceof-check) +export { ArgsValidationError } from './error/error.js'; + // Error raising function export { raise } from './raise/index.js'; diff --git a/packages/errors/tests/edge-cases.test.ts b/packages/errors/tests/edge-cases.test.ts new file mode 100644 index 0000000..7a0816b --- /dev/null +++ b/packages/errors/tests/edge-cases.test.ts @@ -0,0 +1,212 @@ +// Edge case tests for the standard-schema runtime path. + +import { describe, it, expect, vi } from 'vitest'; +import { error, ArgsValidationError } from '../src/index.js'; + +function makeSchema(opts: { + vendor: string; + validate: ( + input: unknown + ) => + | { value: TOutput } + | { issues: ReadonlyArray<{ message: string; path?: ReadonlyArray }> } + | Promise<{ value: TOutput } | { issues: ReadonlyArray<{ message: string }> }>; +}): import('@standard-schema/spec').StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: opts.vendor, + validate: opts.validate as never, + }, + } as never; +} + +describe('standard schema runtime: edge cases', () => { + it('async validator throws ArgsValidationError with an explicit message', () => { + const E = error({ + name: 'AsyncE', + fields: makeSchema({ + vendor: 'async-vendor', + validate: async () => ({ value: { x: 1 } }), + }), + message: (data: { x: number }) => String(data.x), + }); + try { + E({ x: 1 }); + throw new Error('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ArgsValidationError); + expect((err as ArgsValidationError).message).toContain('Async schemas'); + expect((err as ArgsValidationError).vendor).toBe('async-vendor'); + } + }); + + it('validator that throws is wrapped in ArgsValidationError', () => { + const E = error({ + name: 'ThrowE', + fields: makeSchema({ + vendor: 'throwing', + validate: () => { + throw new Error('kaboom'); + }, + }), + message: (data: { ok: boolean }) => String(data.ok), + }); + expect(() => E({ ok: true })).toThrow(/kaboom/); + }); + + it('issues array with non-conformant shape is preserved as-is', () => { + const weirdIssues = ['string', 42, { totally: 'weird' }]; + const E = error({ + name: 'WeirdE', + fields: makeSchema({ + vendor: 'weird', + validate: () => ({ issues: weirdIssues as never }), + }), + message: (data: unknown) => String(data), + }); + try { + E({}); + throw new Error('expected throw'); + } catch (err) { + const ave = err as ArgsValidationError; + expect(ave).toBeInstanceOf(ArgsValidationError); + expect(ave.issues).toEqual(weirdIssues); + expect(ave.message).toContain('Argument validation failed'); + } + }); + + it('value with circular references does not break the wrapper', () => { + type Cycle = { name: string; self?: Cycle }; + const cycle: Cycle = { name: 'loop' }; + cycle.self = cycle; + + const E = error({ + name: 'CycleE', + fields: makeSchema({ + vendor: 'cycle', + validate: () => ({ value: cycle }), + }), + message: (data: Cycle) => data.name, + }); + const instance = E(cycle); + expect(instance.message).toBe('loop'); + }); + + it('schema is invoked exactly once per error construction', () => { + let callCount = 0; + const E = error({ + name: 'CountE', + fields: makeSchema({ + vendor: 'counter', + validate: (input) => { + callCount += 1; + return { value: { x: input } }; + }, + }), + message: (data: { x: unknown }) => String(data.x), + }); + E({ x: 1 }); + E({ x: 2 }); + E({ x: 3 }); + expect(callCount).toBe(3); + }); +}); + +describe('standard schema runtime: more edge cases', () => { + it('legacy string template still works when no fields is supplied', () => { + const E = error<{ a: string }>({ + name: 'LegacyE', + message: 'Hello {a}', + }); + const instance = E({ a: 'world' }); + expect(instance.message).toBe('Hello world'); + }); + + it('legacy string template renders the default name when fields missing', () => { + const E = error({ + name: 'NameOnly', + message: 'Hello {nonexistent}', + }); + const instance = E({}); + expect(instance.message).toBe('Hello {nonexistent}'); + }); + + it('empty function message produces empty string', () => { + const E = error({ + name: 'EmptyE', + fields: makeSchema({ + vendor: 'empty', + validate: () => ({ value: {} }), + }), + message: () => '', + }); + const instance = E({}); + expect(instance.message).toBe(''); + }); + + it('function message that throws is wrapped in a new Error', () => { + const E = error({ + name: 'MessageThrowE', + fields: makeSchema({ + vendor: 'throwmsg', + validate: () => ({ value: {} }), + }), + message: () => { + throw new Error('user-message-bug'); + }, + }); + expect(() => E({})).toThrow(/user-message-bug/); + }); + + it('schema returning Promise but not awaited is rejected loudly', () => { + const E = error({ + name: 'PromiseSchemaE', + fields: makeSchema({ + vendor: 'promise-rejector', + validate: () => new Promise(() => {}), + }), + message: (data: { ok: boolean }) => String(data.ok), + }); + expect(() => E({ ok: true })).toThrow(ArgsValidationError); + }); + + it('error name and stack are preserved correctly on the wrapper', () => { + const E = error({ + name: 'StackE', + fields: makeSchema({ + vendor: 'stack', + validate: () => ({ issues: [{ message: 'x' }] }), + }), + message: (data: unknown) => String(data), + }); + try { + E({}); + throw new Error('expected throw'); + } catch (err) { + expect((err as ArgsValidationError).name).toBe('ArgsValidationError'); + expect((err as Error).stack).toBeDefined(); + expect((err as Error).stack).toContain('ArgsValidationError'); + } + }); + + it('suppresses deprecation warning when DEESSEJS_ERRORS_LEGACY_TEMPLATES=1', () => { + const gate = process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES; + process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES = '1'; + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const E = error({ name: 'GatedE', message: '{nonexistent}' }); + E({}); + E({}); + E({}); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + if (gate === undefined) { + delete process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES; + } else { + process.env.DEESSEJS_ERRORS_LEGACY_TEMPLATES = gate; + } + } + }); +}); diff --git a/packages/errors/tests/integration/arktype/vendor.test.ts b/packages/errors/tests/integration/arktype/vendor.test.ts new file mode 100644 index 0000000..cbfcdf1 --- /dev/null +++ b/packages/errors/tests/integration/arktype/vendor.test.ts @@ -0,0 +1,53 @@ +/** + * Integration tests for arktype with the new standard-schema mode (RFC 0001). + * + * These tests are colocated under tests/integration/ because they depend + * on an external package. They exercise the runtime dispatch end-to-end + * through error(). + */ + +import { describe, it, expect } from 'vitest'; +import { type } from '@ark/type'; +import { ArgsValidationError, error } from '../../../src/error/error.js'; + +describe('arktype 2', () => { + it('renders the message on a passing input', () => { + const E = error({ + name: 'ArkError', + fields: type({ + name: 'string', + 'age?': 'number', + }), + message: (data: { name: string; age?: number }) => `${data.name} ${data.age ?? '(unknown)'}`, + }); + const instance = E({ name: 'ada', age: 36 }); + expect(instance.message).toBe('ada 36'); + }); + + it('throws ArgsValidationError on a failing input', () => { + const E = error({ + name: 'ArkError', + fields: type({ name: 'string' }), + message: (data: { name: string }) => data.name, + }); + expect(() => E({ name: 42 as unknown as string })).toThrow(ArgsValidationError); + }); + + it('exposes the issues and vendor on failure', () => { + const E = error({ + name: 'ArkIssue', + fields: type({ name: 'string' }), + message: (data: { name: string }) => data.name, + }); + let caught: unknown = null; + try { + E({ name: 42 as unknown as string }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ArgsValidationError); + const ae = caught as ArgsValidationError; + expect(ae.vendor).toBe('arktype'); + expect(ae.issues.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/errors/tests/integration/contract-parity.test.ts b/packages/errors/tests/integration/contract-parity.test.ts new file mode 100644 index 0000000..7294fb6 --- /dev/null +++ b/packages/errors/tests/integration/contract-parity.test.ts @@ -0,0 +1,32 @@ +/** + * Cross-vendor parity check: every supported Standard Schema implementation + * must expose a `~standard` namespace with version=1 and a vendor string. + * + * Each vendor is parametrized through it.each so a failure points at the + * specific implementation that drifted off spec. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import * as v from 'valibot'; +import { type } from '@ark/type'; + +describe('vendor-neutral contract parity', () => { + const schemas: Array<[string, () => unknown]> = [ + ['zod', () => z.object({ x: z.string() })], + ['valibot', () => v.object({ x: v.string() })], + ['arktype', () => type({ x: 'string' })], + ]; + + it.each(schemas)( + "%s exposes '~standard' with version 1 and a vendor string", + (_name, factory) => { + const schema = factory() as { + '~standard': { version: number; vendor: string }; + }; + expect(schema['~standard']).toBeDefined(); + expect(schema['~standard'].version).toBe(1); + expect(typeof schema['~standard'].vendor).toBe('string'); + } + ); +}); diff --git a/packages/errors/tests/integration/valibot/vendor.test.ts b/packages/errors/tests/integration/valibot/vendor.test.ts new file mode 100644 index 0000000..b46f4f1 --- /dev/null +++ b/packages/errors/tests/integration/valibot/vendor.test.ts @@ -0,0 +1,58 @@ +/** + * Integration tests for valibot with the new standard-schema mode (RFC 0001). + * + * These tests are colocated under tests/integration/ because they depend + * on an external package. They exercise the runtime dispatch end-to-end + * through error(). + */ + +import { describe, it, expect } from 'vitest'; +import * as v from 'valibot'; +import { ArgsValidationError, error } from '../../../src/error/error.js'; + +describe('valibot 1', () => { + it('renders the message on a passing input', () => { + const E = error({ + name: 'ValibotError', + fields: v.object({ + tag: v.picklist(['info', 'warn', 'error']), + message: v.string(), + }), + message: (data: { tag: string; message: string }) => `[${data.tag}] ${data.message}`, + }); + const instance = E({ tag: 'info', message: 'hello' }); + expect(instance.message).toBe('[info] hello'); + expect(instance.fields).toEqual({ tag: 'info', message: 'hello' }); + }); + + it('throws ArgsValidationError on a failing input', () => { + const E = error({ + name: 'ValibotError', + fields: v.object({ + tag: v.picklist(['info', 'warn', 'error']), + }), + message: (data: { tag: string }) => data.tag, + }); + expect(() => E({ tag: 'weird' })).toThrow(ArgsValidationError); + }); + + it('exposes the issues and vendor on failure', () => { + const E = error({ + name: 'ValibotIssue', + fields: v.object({ + count: v.pipe(v.number(), v.minValue(0)), + }), + message: (data: { count: number }) => String(data.count), + }); + let caught: unknown = null; + try { + E({ count: -1 }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ArgsValidationError); + const ae = caught as ArgsValidationError; + expect(ae.vendor).toBe('valibot'); + expect(ae.issues.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/errors/tests/integration/zod/vendor.test.ts b/packages/errors/tests/integration/zod/vendor.test.ts new file mode 100644 index 0000000..bb182d1 --- /dev/null +++ b/packages/errors/tests/integration/zod/vendor.test.ts @@ -0,0 +1,76 @@ +/** + * Integration tests for zod with the new standard-schema mode (RFC 0001). + * + * These tests are colocated under tests/integration/ because they depend + * on an external package. They exercise the runtime dispatch end-to-end + * through error(). + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { ArgsValidationError, error } from '../../../src/error/error.js'; + +describe('zod 4', () => { + it('renders the message on a passing input', () => { + const E = error({ + name: 'ZodValidationError', + fields: z.object({ + email: z.string().email(), + age: z.number().int().min(0), + }), + message: (data: { email: string; age: number }) => `Field "${data.email}" age ${data.age}`, + }); + const instance = E({ email: 'jane@example.com', age: 30 }); + expect(instance.message).toBe('Field "jane@example.com" age 30'); + expect(instance.fields).toEqual({ + email: 'jane@example.com', + age: 30, + }); + expect(instance.name).toBe('ZodValidationError'); + }); + + it('throws ArgsValidationError on a failing input', () => { + const E = error({ + name: 'ZodValidationError', + fields: z.object({ + email: z.string().email(), + }), + message: (data: { email: string }) => `Field "${data.email}"`, + }); + expect(() => E({ email: 'not-an-email' })).toThrow(ArgsValidationError); + }); + + it('exposes the issues and vendor on failure', () => { + const E = error({ + name: 'ZodError', + fields: z.object({ + email: z.string().email(), + }), + message: (data: { email: string }) => `Field ${data.email}`, + }); + let caught: unknown = null; + try { + E({ email: 'bogus' }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ArgsValidationError); + const ae = caught as ArgsValidationError; + expect(ae.vendor).toBe('zod'); + expect(ae.issues.length).toBeGreaterThan(0); + expect(ae.source).toBe('ZodError'); + }); + + it('preserves transforms in the output type', () => { + const E = error({ + name: 'ZodTransform', + fields: z.object({ + value: z.coerce.number(), + }), + message: (data: { value: number }) => String(data.value), + }); + const instance = E({ value: '42' }); + expect(typeof instance.fields.value).toBe('number'); + expect(instance.fields.value).toBe(42); + }); +}); diff --git a/packages/errors/tests/perf/instantiate.bench.ts b/packages/errors/tests/perf/instantiate.bench.ts new file mode 100644 index 0000000..cca9137 --- /dev/null +++ b/packages/errors/tests/perf/instantiate.bench.ts @@ -0,0 +1,28 @@ +// Performance benchmarks. Run with: pnpm exec vitest bench +// Not part of the regular test run; benchmarks are informational. + +import { bench, describe } from 'vitest'; +import { z } from 'zod'; +import { error } from '../../src/index.ts'; + +const NoFields = error({ name: 'NoFields' }); +const WithFields = error({ + name: 'WithFields', + fields: z.object({ x: z.string() }), + message: (data: { x: string }) => data.x, +}); +const Legacy = error<{ a: string }>({ name: 'Legacy', message: 'Hello {a}' }); + +describe('error factory instantiation', () => { + bench('no fields, no message', () => { + NoFields(); + }); + + bench('with zod schema, function message', () => { + WithFields({ x: 'hello' }); + }); + + bench('legacy string-template form', () => { + Legacy({ a: 'world' }); + }); +}); diff --git a/packages/errors/tests/snapshots/exports.test.ts b/packages/errors/tests/snapshots/exports.test.ts new file mode 100644 index 0000000..814ef88 --- /dev/null +++ b/packages/errors/tests/snapshots/exports.test.ts @@ -0,0 +1,39 @@ +// Public surface snapshot. Locks the list of named exports from +// @deessejs/errors so accidental removals or renames are caught by +// CI. Also locks the runtime identity of the exported classes. + +import { describe, it, expect } from 'vitest'; +import * as errors from '../../src/index.js'; + +describe('public surface snapshot', () => { + it('exports the expected set of runtime and type members', () => { + expect(Object.keys(errors).sort()).toEqual( + ['ArgsValidationError', 'causes', 'error', 'is', 'raise'].sort() + ); + }); + + it('ArgsValidationError is a class extending Error', () => { + expect(typeof errors.ArgsValidationError).toBe('function'); + const proto = Object.getPrototypeOf(errors.ArgsValidationError); + expect(proto).toBe(Error); + }); + + it('error, raise, is, causes are functions', () => { + expect(typeof errors.error).toBe('function'); + expect(typeof errors.raise).toBe('function'); + expect(typeof errors.is).toBe('function'); + expect(typeof errors.causes).toBe('function'); + }); + + it('ArgsValidationError accepts a custom source and vendor', () => { + const e = new errors.ArgsValidationError('MyError', [{ message: 'x' }], 'vendor'); + expect(e.source).toBe('MyError'); + expect(e.vendor).toBe('vendor'); + expect(e.name).toBe('ArgsValidationError'); + }); + + it('ArgsValidationError message includes the source', () => { + const e = new errors.ArgsValidationError('MyError', [], 'vendor'); + expect(e.message).toContain('MyError'); + }); +}); diff --git a/packages/errors/tests/standard-schema.test.ts b/packages/errors/tests/standard-schema.test.ts new file mode 100644 index 0000000..f6517b9 --- /dev/null +++ b/packages/errors/tests/standard-schema.test.ts @@ -0,0 +1,194 @@ +/** + * Unit tests for the new-style error factory: function-form message + Standard + * Schema validation (RFC 0001). + * + * These tests use a mock Standard Schema, not zod or valibot, so the suite + * stays self-contained. Vendor-specific tests (zod, valibot, arktype) live + * in the consumer-facing docs site; this suite verifies the contract. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ArgsValidationError, error } from '../src/error/error.js'; +import type { StandardSchemaV1 } from '../src/index.js'; + +// Build a Standard Schema validator from a plain function. Mirrors zod's +// `safeParse` shape: returns either `{ value }` or `{ issues }`. +const schema = ( + predicate: (input: unknown) => input is T, + validator: string = 'mock' +): StandardSchemaV1 => ({ + '~standard': { + version: 1, + vendor: validator, + validate: (input: unknown) => + predicate(input) + ? { value: input as T } + : { + issues: [ + { + message: `Predicted value did not match "${validator}"`, + path: [], + }, + ], + }, + }, +}); + +describe('error() with Standard Schema (RFC 0001)', () => { + let warnSpy: ReturnType | null = null; + + beforeEach(() => { + // Silence legacy-form warning emitted to stderr during the legacy tests. + if (!warnSpy) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + warnSpy = null; + } + }); + + afterEach(() => { + if (warnSpy && typeof (warnSpy as { mockRestore?: () => void }).mockRestore === 'function') { + (warnSpy as { mockRestore: () => void }).mockRestore(); + } + }); + + describe('legacy string-template form', () => { + it('accepts only name', () => { + const Err = error({ name: 'LegacyError' }); + const instance = Err(); + expect(instance.message).toBe('LegacyError'); + expect(instance.name).toBe('LegacyError'); + }); + + it('interpolates {placeholder} template', () => { + const Err = error({ + name: 'LegacyError', + message: 'Field "{field}" is invalid: {reason}', + }); + const instance = Err({ field: 'email', reason: 'format' }); + expect(instance.message).toBe('Field "email" is invalid: format'); + }); + + it('uses message as-is when no placeholders', () => { + const Err = error({ + name: 'LegacyError', + message: 'Plain message', + }); + const instance = Err(); + expect(instance.message).toBe('Plain message'); + }); + }); + + describe('standard form with a passing schema', () => { + it('renders the message from the function', () => { + const Fields = schema<{ name: string }>( + (v): v is { name: string } => + typeof v === 'object' && v !== null && typeof (v as { name: unknown }).name === 'string' + ); + const GreetingError = error({ + name: 'GreetingError', + fields: Fields, + message: (data: { name: string }) => `Hello, ${data.name}!`, + }); + const instance = GreetingError({ name: 'world' }); + expect(instance.message).toBe('Hello, world!'); + expect(instance.fields).toEqual({ name: 'world' }); + }); + + it('exposes the schema on the factory', () => { + const Fields = schema<{ x: number }>( + (v): v is { x: number } => + typeof v === 'object' && v !== null && typeof (v as { x: unknown }).x === 'number' + ); + const E = error({ + name: 'E', + fields: Fields, + message: (d: { x: number }) => String(d.x), + }); + expect((E as unknown as { schema: unknown }).schema).toBe(Fields); + }); + }); + + describe('standard form with a failing schema', () => { + it('throws ArgsValidationError on a bad input', () => { + const Fields = schema<{ ok: true }>((): v is { ok: true } => false, 'test-validator'); + const E = error({ + name: 'BadInputError', + fields: Fields, + message: (d: { ok: true }) => String(d.ok), + }); + expect(() => E({ wrong: true })).toThrow(ArgsValidationError); + }); + + it('exposes the source name and issues on the thrown error', () => { + const Fields = schema<{ ok: true }>((): v is { ok: true } => false); + const E = error({ + name: 'BadInputError', + fields: Fields, + message: (d: { ok: true }) => String(d.ok), + }); + let caught: unknown = null; + try { + E({}); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ArgsValidationError); + expect((caught as ArgsValidationError).source).toBe('BadInputError'); + expect(Array.isArray((caught as ArgsValidationError).issues)).toBe(true); + expect((caught as ArgsValidationError).name).toBe('ArgsValidationError'); + }); + + it('exposes the validator vendor', () => { + const Fields = schema<{ ok: true }>((): v is { ok: true } => false, 'arcane-vendor'); + const E = error({ + name: 'V', + fields: Fields, + message: (d: { ok: true }) => String(d.ok), + }); + try { + E({}); + } catch (err) { + expect((err as ArgsValidationError).vendor).toBe('arcane-vendor'); + } + }); + }); + + describe('ArgsValidationError class', () => { + it('extends Error', () => { + const e = new ArgsValidationError('X', [{ message: 'oops' }], 'mock'); + expect(e).toBeInstanceOf(Error); + expect(e).toBeInstanceOf(ArgsValidationError); + }); + + it('exposes source, vendor, issues', () => { + const issues = [{ message: 'first' }, { message: 'second' }]; + const e = new ArgsValidationError('Src', issues, 'mock-vendor'); + expect(e.source).toBe('Src'); + expect(e.vendor).toBe('mock-vendor'); + expect(e.issues).toBe(issues); + }); + + it('serializes issues into message', () => { + const e = new ArgsValidationError('X', [{ message: 'first' }], 'mock'); + expect(e.message).toContain('Argument validation failed for "X"'); + expect(e.message).toContain('first'); + }); + }); + + describe('legacy form retains legacy schema field exposure', () => { + it('exposes the schema on the factory even when message is a string', () => { + // Per RFC 0001 decision A, the schema field on the factory is kept in + // the legacy path so introspection tools still work. + const Fields = schema<{ name: string }>( + (v): v is { name: string } => + typeof v === 'object' && v !== null && typeof (v as { name: unknown }).name === 'string' + ); + const E = error({ + name: 'MixedError', + fields: Fields, + message: 'Legacy template {name}', + }); + expect((E as unknown as { schema: unknown }).schema).toBe(Fields); + }); + }); +}); diff --git a/packages/errors/tests/types/error-type.test.ts b/packages/errors/tests/types/error-type.test.ts new file mode 100644 index 0000000..f1227b8 --- /dev/null +++ b/packages/errors/tests/types/error-type.test.ts @@ -0,0 +1,143 @@ +// Static type tests for the error() factory. They live under tests/types/ and use expectTypeOf to assert types. + +import { describe, it, expectTypeOf } from 'vitest'; +import { z } from 'zod'; +import * as v from 'valibot'; +import { type } from '@ark/type'; +import { error, raise, ArgsValidationError } from '../../src/index.js'; + +describe('error() type inference (Standard Schema mode)', () => { + it('infers the field type from a zod schema', () => { + const E = error({ + name: 'ZodError', + fields: z.object({ x: z.string() }), + message: (data: { x: string }) => data.x, + }); + const instance = E({ x: 'hello' }); + expectTypeOf(instance).toMatchTypeOf<{ x: string; name: string; message: string }>(); + expectTypeOf(instance.fields).toEqualTypeOf<{ x: string }>(); + }); + + it('infers the field type from a valibot schema', () => { + const E = error({ + name: 'ValibotError', + fields: v.object({ count: v.number() }), + message: (data: { count: number }) => String(data.count), + }); + const instance = E({ count: 42 }); + expectTypeOf(instance.fields).toEqualTypeOf<{ count: number }>(); + }); + + it('infers the field type from an arktype schema', () => { + const E = error({ + name: 'ArkError', + fields: type({ ok: 'boolean' }), + message: (data: { ok: boolean }) => String(data.ok), + }); + const instance = E({ ok: true }); + expectTypeOf(instance.fields).toEqualTypeOf<{ ok: boolean }>(); + }); + + it('preserves transformed output types in the message function', () => { + const E = error({ + name: 'CoerceError', + fields: z.object({ n: z.coerce.number() }), + message: (data: { n: number }) => String(data.n), + }); + const instance = E({ n: '42' }); + // After z.coerce, data.n is number, not string. + expectTypeOf(instance.fields.n).toEqualTypeOf(); + expectTypeOf(instance.fields.n).not.toEqualTypeOf(); + }); + + it('preserves branded types from zod', () => { + const UserId = z.string().regex(/^usr_/).brand<'UserId'>(); + const E = error({ + name: 'BrandedError', + fields: z.object({ id: UserId }), + message: (data: { id: string & { __brand: 'UserId' } }) => data.id, + }); + const instance = E({ id: 'usr_1' as string & { __brand: 'UserId' } }); + expectTypeOf(instance.fields.id).toMatchTypeOf(); + }); +}); + +describe('error() without fields (manual generic)', () => { + it('respects the manually supplied generic', () => { + const E = error<{ a: string; b: number }>({ name: 'ManualError' }); + const instance = E({ a: 'hi', b: 1 }); + expectTypeOf(instance.fields).toEqualTypeOf<{ a: string; b: number }>(); + }); + + it('defaults fields to {} when no generic is provided', () => { + const E = error({ name: 'DefaultError' }); + const instance = E(); + expectTypeOf(instance.fields).toEqualTypeOf>(); + }); +}); + +describe('error() instance shape', () => { + it('the instance has the documented core fields', () => { + const E = error({ name: 'ShapeError' }); + const instance = E(); + expectTypeOf(instance.name).toEqualTypeOf(); + expectTypeOf(instance.message).toEqualTypeOf(); + expectTypeOf(instance.stack).toEqualTypeOf(); + expectTypeOf(instance.cause).toEqualTypeOf(); + expectTypeOf(instance.causes).toEqualTypeOf(); + expectTypeOf(instance.notes).toEqualTypeOf(); + expectTypeOf(instance.context).toEqualTypeOf | null>(); + }); + + it('the instance methods are bound and chainable', () => { + const E = error({ name: 'ChainError' }); + const a = E(); + const b = a.addNote('n1').addNote('n2'); + expectTypeOf(b.notes).toEqualTypeOf<[string, string]>(); + + const cause = new Error('c'); + const c = b.from(cause); + expectTypeOf(c.cause).toEqualTypeOf(); + }); +}); + +describe('error() legacy path', () => { + it('accepts a string message with the legacy template form', () => { + const E = error<{ name: string }>({ + name: 'Legacy', + message: 'Hello {name}', + }); + const instance = E({ name: 'Ada' }); + expectTypeOf(instance.message).toEqualTypeOf(); + expectTypeOf(instance.fields).toEqualTypeOf<{ name: string }>(); + }); + + it('the legacy form is still valid TypeScript', () => { + // Runtime warning is covered indirectly by the existing legacy form tests. + // Asserting the call-site collection here is brittle (stack format, mock + // ordering), so we just lock the type contract. + const E = error<{ a: string }>({ name: 'Legacy', message: '{a}' }); + const instance = E({ a: 'x' }); + expectTypeOf(instance.message).toEqualTypeOf(); + }); +}); + +describe('ArgsValidationError type contract', () => { + it('is constructible with source, issues, vendor', () => { + const e = new ArgsValidationError('X', [{ message: 'oops' }], 'mock'); + expectTypeOf(e).toMatchTypeOf(); + expectTypeOf(e.source).toEqualTypeOf(); + expectTypeOf(e.issues).toEqualTypeOf>(); + expectTypeOf(e.vendor).toEqualTypeOf(); + }); +}); + +describe('raise() return type', () => { + it('raises are typed as never', () => { + const E = error({ name: 'RaiseError' }); + // The return type is `never`. The line below is a compile-time check: + // the expression must compile, and the inferred return is `never`. + const r = (): never => raise(E()); + expectTypeOf(r).returns.toEqualTypeOf(); + }); +}); diff --git a/packages/errors/vitest.config.ts b/packages/errors/vitest.config.ts index cd31527..ad4ed8d 100644 --- a/packages/errors/vitest.config.ts +++ b/packages/errors/vitest.config.ts @@ -5,5 +5,8 @@ export default defineConfig({ globals: true, environment: 'node', include: ['tests/**/*.ts'], + // Benchmarks live under tests/perf/ and use vitest's bench API. + // They are picked up by `pnpm exec vitest bench` but skipped by `test:run`. + exclude: ['node_modules/**', 'tests/perf/**'], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbeda7f..7a34fc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: specifier: ^1.1.0 version: 1.1.0 devDependencies: + '@ark/type': + specifier: ^2.2.3 + version: 2.2.3 '@eslint/js': specifier: ^9.0.0 version: 9.39.4 @@ -119,9 +122,15 @@ importers: typescript-eslint: specifier: ^8.0.0 version: 8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@6.0.3) vitest: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + zod: + specifier: ^4.4.3 + version: 4.4.3 packages: @@ -129,6 +138,15 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ark/schema@0.56.2': + resolution: {integrity: sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==} + + '@ark/type@2.2.3': + resolution: {integrity: sha512-67iA4Eg+F6ItePvGJshxcagCkU0xXw++ANX8Q9A3f0crgBEEVk2Fp40JYH4qDCtO2DuFaVuz/U1oOojTI9u3fw==} + + '@ark/util@0.56.2': + resolution: {integrity: sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1750,6 +1768,9 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + arkregex@0.0.8: + resolution: {integrity: sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -3890,6 +3911,14 @@ packages: '@types/react': optional: true + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -4056,6 +4085,18 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ark/schema@0.56.2': + dependencies: + '@ark/util': 0.56.2 + + '@ark/type@2.2.3': + dependencies: + '@ark/schema': 0.56.2 + '@ark/util': 0.56.2 + arkregex: 0.0.8 + + '@ark/util@0.56.2': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -5551,6 +5592,10 @@ snapshots: aria-query@5.3.2: {} + arkregex@0.0.8: + dependencies: + '@ark/util': 0.56.2 + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -6003,8 +6048,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.6 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) @@ -6026,7 +6071,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -6037,21 +6082,21 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6062,7 +6107,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -8357,6 +8402,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.15 + valibot@1.4.2(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3