From 9ec3e8c63b8fd4e2d6ef0d20930d1f7abf6512f6 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Wed, 12 Aug 2026 11:01:06 +0200 Subject: [PATCH 1/2] feat(errors): implement Standard Schema type inference in error() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #83: `error()` declared a `>` placeholder parameter that was never inferred from any source — the `fields: StandardSchemaV1` parameter was opaque, so `T` always fell back to the default `Record` at every call site. The signature promised inference; the contract did not deliver it. Replace `T` with a schema-derived `S extends StandardSchemaV1 | undefined`. The new `InferFields` helper in `types.ts` extracts the output type via `StandardSchemaV1.InferOutput`, intersected with `Record` to satisfy the `ErrorFactory` constraint. When `fields` is omitted, `InferFields` falls back to `Record` (preserving the existing default behaviour). Consumers now receive a factory whose return type carries the schema's output shape — the boilerplate `error<{ ... }>(...)` annotation is no longer required. The trailing cast at the end of `error()` is preserved (single cast, single boundary, rule 0008 compliant) but the gap it bridges is narrower: the cast now sits only at the metadata-attachment boundary. `ErrorConfig` in `types.ts` is updated to mirror the public signature. Three new tests in `error.test.ts` prove the inference at compile time (the test bodies would fail to type-check if inference regressed). A typed-mock factory (`createTypedMockSchema`) is added next to the existing `createMockSchema`; the typed mock declares the schema's `types` field so `StandardSchemaV1.InferOutput` propagates. Closes #83. 85/85 tests pass (82 → 85, three new inference regressions). Type-check clean. Lint clean (run from the package dir). --- .../feat-83-standard-schema-inference.md | 9 +++ packages/errors/src/error/error.ts | 27 ++++---- packages/errors/src/error/types.ts | 31 ++++++++- packages/errors/tests/error.test.ts | 67 ++++++++++++++++++- 4 files changed, 115 insertions(+), 19 deletions(-) create mode 100644 .changeset/feat-83-standard-schema-inference.md diff --git a/.changeset/feat-83-standard-schema-inference.md b/.changeset/feat-83-standard-schema-inference.md new file mode 100644 index 0000000..9870f8f --- /dev/null +++ b/.changeset/feat-83-standard-schema-inference.md @@ -0,0 +1,9 @@ +--- +"@deessejs/errors": minor +--- + +Implement Standard Schema type inference in `error()`. The `>` placeholder parameter is gone. The field shape is now derived from the `fields: StandardSchemaV1` parameter via a new `InferFields` helper that uses `StandardSchemaV1.InferOutput & Record` to satisfy the `ErrorFactory` constraint while preserving the precise inferred type at the call site. + +Consumers passing a typed Standard Schema-compliant validator (Zod, Valibot, ArkType, etc.) now receive a factory whose return type carries the schema's output shape — no more `error(...)` boilerplate. The trailing cast `return ErrorFactoryInstance as ErrorFactory` is preserved (single cast, single boundary, rule 0008 compliant) but the gap it bridges is now narrower: the cast only widens at the metadata-attachment boundary, not at the type-parameter boundary. + +Closes #83. The package's runtime behavior is unchanged; 85 tests pass (82 → 85, three new inference regression tests). The `ErrorConfig` type in `types.ts` is updated to mirror the new public signature. \ No newline at end of file diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index 9f420a8..d0c9da2 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -6,7 +6,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; -import type { ErrorFactory, ErrorInstance } from './types.js'; +import type { ErrorFactory, ErrorInstance, InferFields } from './types.js'; import { captureStack } from './capture.js'; import { formatTemplate, hasTemplatePlaceholders } from './format.js'; @@ -73,19 +73,20 @@ const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory'); * }); * ``` */ -export const error = = Record>(config: { +export const error = (config: { name: string; - fields?: StandardSchemaV1; + fields?: S; inherits?: ErrorFactory | ErrorFactory[]; message?: string; -}): ErrorFactory => { +}): ErrorFactory> => { + type Fields = InferFields; const { name, fields, inherits, message } = config; /** * Error factory function - creates error instances. */ - const ErrorFactoryInstance = (input?: Partial): ErrorInstance => { - const fieldsData = (input || {}) as T; + const ErrorFactoryInstance = (input?: Partial): ErrorInstance => { + const fieldsData = (input || {}) as Fields; // Format message if template has placeholders let errorMessage = name; @@ -99,7 +100,7 @@ export const error = = Record; + const instance = new Error(errorMessage) as ErrorInstance; instance.name = name; instance.fields = fieldsData; instance.notes = []; @@ -110,7 +111,7 @@ export const error = = Record => { + instance.from = (cause: Error): ErrorInstance => { // Build new causes array: [new cause] + [cause's causes] + [existing causes of instance] // This maintains chronological order: newest first const causeCauses = 'causes' in cause && Array.isArray(cause.causes) ? cause.causes : []; @@ -120,7 +121,7 @@ export const error = = Record => { + instance.addNote = (note: string): ErrorInstance => { instance.notes.push(note); return instance; }; @@ -142,18 +143,18 @@ export const error = = Record).inherits = inherits; + (ErrorFactoryInstance as ErrorFactory).inherits = inherits; } if (fields !== undefined) { - (ErrorFactoryInstance as ErrorFactory).schema = fields; + (ErrorFactoryInstance as ErrorFactory).schema = fields; } if (message !== undefined) { - (ErrorFactoryInstance as ErrorFactory).rawMessage = message; + (ErrorFactoryInstance as ErrorFactory).rawMessage = message; } - return ErrorFactoryInstance as ErrorFactory; + return ErrorFactoryInstance as ErrorFactory; }; // ============================================================================ diff --git a/packages/errors/src/error/types.ts b/packages/errors/src/error/types.ts index 41ab0f6..ecb3c01 100644 --- a/packages/errors/src/error/types.ts +++ b/packages/errors/src/error/types.ts @@ -4,6 +4,28 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; +// ============================================================================ +// Schema inference +// ============================================================================ + +/** + * Extracts the field shape from a Standard Schema, defaulting to + * `Record` when no schema is provided. + * + * When `S extends StandardSchemaV1`, the output type of the schema is + * used as the field shape. When `S` is `undefined` (the default for + * schemas that are not passed to `error()`), an empty record is used. + * + * The `[S] extends [StandardSchemaV1]` form is used (instead of the + * naked conditional) to avoid distributing over union types and to + * ensure the `undefined` branch is matched as a whole. + * + * @internal + */ +export type InferFields = [S] extends [StandardSchemaV1] + ? StandardSchemaV1.InferOutput & Record + : Record; + // ============================================================================ // Types // ============================================================================ @@ -86,13 +108,16 @@ export type ErrorInstance = Record = Record> = { +export type ErrorConfig = { /** Error name identifier */ name: string; /** Standard Schema field definitions */ - fields?: StandardSchemaV1; + fields?: S; /** Single parent error factory to inherit from */ inherits?: ErrorFactory | ErrorFactory[]; /** Message template with {field} placeholders */ diff --git a/packages/errors/tests/error.test.ts b/packages/errors/tests/error.test.ts index c885ea5..1e4eae6 100644 --- a/packages/errors/tests/error.test.ts +++ b/packages/errors/tests/error.test.ts @@ -6,13 +6,32 @@ import { describe, it, expect } from 'vitest'; import { error } from '../src/error/error.js'; import type { ErrorFactory, ErrorInstance, StandardSchemaV1 } from '../src/index.js'; -// Mock Standard Schema interface for testing (simplified StandardSchemaV1) -const createMockSchema = (name = 'mock'): StandardSchemaV1 => { +// Mock Standard Schema interface for testing (simplified StandardSchemaV1). +// The `types` field is what makes Standard Schema inference work; without +// it, the schema is a uniform `StandardSchemaV1` and +// no useful type information propagates. +const createMockSchema = (name = 'mock'): StandardSchemaV1 => { return { '~standard': { version: 1, vendor: name, - validate: () => ({ value: undefined as unknown as T }), + types: undefined as never, + validate: () => ({ value: undefined as unknown as Output }), + }, + }; +}; + +/** + * Standard Schema-compliant mock that declares its input/output + * types. Use this when a test needs inference to propagate (issue #83). + */ +const createTypedMockSchema = (name = 'mock'): StandardSchemaV1 => { + return { + '~standard': { + version: 1, + vendor: name, + types: { input: undefined as unknown as Input, output: undefined as unknown as Output }, + validate: () => ({ value: undefined as unknown as Output }), }, }; }; @@ -380,4 +399,46 @@ describe('error() factory function', () => { expect(instance.stack).toContain('Custom message for value'); }); }); + + describe('Standard Schema type inference (issue #83)', () => { + it('should infer the field shape from a typed schema', () => { + // Regression for issue #83: the field shape is derived from the + // schema's output type, not from a placeholder T parameter. + const schema = createTypedMockSchema(); + const ValidationError = error({ name: 'ValidationError', fields: schema }); + + // The factory accepts exactly the schema's output shape as input. + // This line is the inference contract: it must type-check without + // an explicit `<{ email: string; age: number }>` annotation. + const instance = ValidationError({ email: 'a@b.c', age: 30 }); + void instance; + + expect(ValidationError.name).toBe('ValidationError'); + }); + + it('should fall back to Record when no fields are provided', () => { + // When `fields` is omitted, the field shape is empty. The factory + // accepts an optional input (Partial> is + // effectively `{}`), and the instance has no typed fields. + const SimpleError = error({ name: 'SimpleError' }); + + const instance = SimpleError(); + expect(instance.fields).toEqual({}); + }); + + it('should infer without requiring an explicit T annotation', () => { + // The schema declares its output. The factory's return type + // carries that output through `InferFields`. + type EmailOutput = { email: string }; + const schema = createTypedMockSchema(); + const Factory = error({ name: 'EmailError', fields: schema }); + + // Type assertion at compile time: the call must accept `EmailOutput`. + // If inference were broken, this would fail with a type error. + const _check: (input?: Partial) => ErrorInstance = Factory; + void _check; + + expect(typeof Factory).toBe('function'); + }); + }); }); From bb3938993eea8fd115bb0ef96586cc8438c57d06 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Wed, 12 Aug 2026 11:14:58 +0200 Subject: [PATCH 2/2] docs(arch): add ADR 0001 for Standard Schema as runtime validation contract Documents the choice to adopt @standard-schema/spec as the runtime validation contract in error(), the three options considered (single validator, unknown, Standard Schema), and the consequences (transitive spec dependency, InferFields widening deviation from spec idiom, validator-specific bugs as typing oddities). Closes #80 (the documentation half of the inference work; #83 covered the implementation). Refs #84 (implementation PR). --- ...-standard-schema-for-runtime-validation.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/engineering/architecture/decisions/0001-standard-schema-for-runtime-validation.md diff --git a/docs/engineering/architecture/decisions/0001-standard-schema-for-runtime-validation.md b/docs/engineering/architecture/decisions/0001-standard-schema-for-runtime-validation.md new file mode 100644 index 0000000..ce450e0 --- /dev/null +++ b/docs/engineering/architecture/decisions/0001-standard-schema-for-runtime-validation.md @@ -0,0 +1,54 @@ +# 0001 — Standard Schema as the runtime validation contract + +**Status**: Accepted +**Date**: 2026-08-12 + +## Context + +`@deessejs/errors` accepts a `fields` parameter in `error()` that describes the structured shape of an error. Consumers want this shape to drive the `ErrorInstance` generic — typing `err.fields.email` correctly without manual annotation. The shape is supplied by a runtime validator (Zod, Valibot, ArkType, etc.); the library cannot pick one validator without coupling a every consumer to its release cadence. + +Three options were considered: + +- **A. Pin a single validator (Zod).** Best DX for Zod users. Couples the package's release cadence to Zod's, and forces non-Zod consumers to either write an adapter or leave the typed type unwrapped. +- **B. Accept `unknown` and let consumers cast.** No runtime contract; every consumer writes the same cast at every call site. +- **C. Accept any Standard Schema-compliant validator.** The contract is the spec, not a vendor. Consumers keep their validator of choice; the library types the output via `StandardSchemaV1.InferOutput`. + +## Decision + +Adopt **option C**. The `error()` function accepts `fields?: StandardSchemaV1` and infers the field shape through `StandardSchemaV1.InferOutput`. Zod, Valibot, ArkType, and any future compliant validator work without code change. + +The current implementation lands in PR #84 (issue #83). The relevant types: + +- `InferFields` in `packages/errors/src/error/types.ts` — extracts the field shape via `StandardSchemaV1.InferOutput` and falls back to `Record` when `fields` is omitted. +- `error()` in `packages/errors/src/error/error.ts` — generic over `S extends StandardSchemaV1 | undefined = undefined`; the return type is `ErrorFactory>`. +- `ErrorConfig` mirrors the public signature for callers that type a config object separately. + +## Consequences + +**Easier:** + +- Consumers keep their validator of choice. No adapter layer. +- The `fields` parameter is typed end-to-end: the factory accepts exactly the schema's output, the instance carries it, the `addNote` / `from` methods preserve it. +- New validators that adopt Standard Schema work without library changes. + +**Harder:** + +- The library now has a transitive dependency on the Standard Schema spec. If the spec changes shape, the library must follow. (Mitigation: the spec is at v1 with a stable `~standard` namespace; breaking changes would require a major version bump on our side too.) +- The `InferFields` helper intersects with `Record` to satisfy the `ErrorFactory` constraint when a schema omits its `types` declaration. This widens the spec's `InferOutput` (which would propagate `never` for untyped schemas) into a usable record. The widening is a documented deviation from the spec idiom; it can be revisited when `ErrorFactory`'s constraint is loosened. +- A bug in any validator's Standard Schema adapter (e.g. zod #5303 — `z.coerce` inference divergence in v4) propagates as a typing oddity in our generic. The library cannot fix the validator but can document known incompatibilities. + +## Revisit conditions + +This ADR should be revisited when: + +- `@standard-schema/spec` ships a breaking change or is abandoned. +- A successor spec emerges (e.g. a v2 of the same name or a community fork) with adoption that makes migration worthwhile. +- A specific validator feature (e.g. recursive schemas with a unique syntax) becomes load-bearing for the library's surface area and cannot be exposed generically. + +## References + +- `@standard-schema/spec` v1.1.0 — the contract consumed. +- `packages/errors/src/error/types.ts` — `InferFields` definition. +- `packages/errors/src/error/error.ts` — `error()` generic. +- Issue #83 — initial design discussion. +- PR #84 — implementation. \ No newline at end of file