Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/feat-83-standard-schema-inference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@deessejs/errors": minor
---

Implement Standard Schema type inference in `error()`. The `<const T extends Record<string, unknown>>` placeholder parameter is gone. The field shape is now derived from the `fields: StandardSchemaV1` parameter via a new `InferFields<S>` helper that uses `StandardSchemaV1.InferOutput<S> & Record<string, unknown>` to satisfy the `ErrorFactory<TFields>` 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<T>(...)` boilerplate. The trailing cast `return ErrorFactoryInstance as ErrorFactory<T>` 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.
Original file line number Diff line number Diff line change
@@ -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<T>` 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<S>`.

## Decision

Adopt **option C**. The `error()` function accepts `fields?: StandardSchemaV1` and infers the field shape through `StandardSchemaV1.InferOutput<S>`. 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<S>` in `packages/errors/src/error/types.ts` — extracts the field shape via `StandardSchemaV1.InferOutput<S>` and falls back to `Record<string, never>` when `fields` is omitted.
- `error()` in `packages/errors/src/error/error.ts` — generic over `S extends StandardSchemaV1 | undefined = undefined`; the return type is `ErrorFactory<InferFields<S>>`.
- `ErrorConfig<S>` 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<S>` helper intersects with `Record<string, unknown>` to satisfy the `ErrorFactory<TFields>` constraint when a schema omits its `types` declaration. This widens the spec's `InferOutput<S>` (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<S>` definition.
- `packages/errors/src/error/error.ts` — `error()` generic.
- Issue #83 — initial design discussion.
- PR #84 — implementation.
27 changes: 14 additions & 13 deletions packages/errors/src/error/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -73,19 +73,20 @@ const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory');
* });
* ```
*/
export const error = <const T extends Record<string, unknown> = Record<string, never>>(config: {
export const error = <const S extends StandardSchemaV1 | undefined = undefined>(config: {
name: string;
fields?: StandardSchemaV1;
fields?: S;
inherits?: ErrorFactory | ErrorFactory[];
message?: string;
}): ErrorFactory<T> => {
}): ErrorFactory<InferFields<S>> => {
type Fields = InferFields<S>;
const { name, fields, inherits, message } = config;

/**
* Error factory function - creates error instances.
*/
const ErrorFactoryInstance = (input?: Partial<T>): ErrorInstance<T> => {
const fieldsData = (input || {}) as T;
const ErrorFactoryInstance = (input?: Partial<Fields>): ErrorInstance<Fields> => {
const fieldsData = (input || {}) as Fields;

// Format message if template has placeholders
let errorMessage = name;
Expand All @@ -99,7 +100,7 @@ export const error = <const T extends Record<string, unknown> = Record<string, n
const stack = captureStack(errorMessage);

// Create error instance using native Error
const instance = new Error(errorMessage) as ErrorInstance<T>;
const instance = new Error(errorMessage) as ErrorInstance<Fields>;
instance.name = name;
instance.fields = fieldsData;
instance.notes = [];
Expand All @@ -110,7 +111,7 @@ export const error = <const T extends Record<string, unknown> = Record<string, n
instance.stack = stack;

// Add .from() method for exception chaining
instance.from = (cause: Error): ErrorInstance<T> => {
instance.from = (cause: Error): ErrorInstance<Fields> => {
// 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 : [];
Expand All @@ -120,7 +121,7 @@ export const error = <const T extends Record<string, unknown> = Record<string, n
};

// Add .addNote() method for runtime context (PEP 678)
instance.addNote = (note: string): ErrorInstance<T> => {
instance.addNote = (note: string): ErrorInstance<Fields> => {
instance.notes.push(note);
return instance;
};
Expand All @@ -142,18 +143,18 @@ export const error = <const T extends Record<string, unknown> = Record<string, n
});

if (inherits !== undefined) {
(ErrorFactoryInstance as ErrorFactory<T>).inherits = inherits;
(ErrorFactoryInstance as ErrorFactory<Fields>).inherits = inherits;
}

if (fields !== undefined) {
(ErrorFactoryInstance as ErrorFactory<T>).schema = fields;
(ErrorFactoryInstance as ErrorFactory<Fields>).schema = fields;
}

if (message !== undefined) {
(ErrorFactoryInstance as ErrorFactory<T>).rawMessage = message;
(ErrorFactoryInstance as ErrorFactory<Fields>).rawMessage = message;
}

return ErrorFactoryInstance as ErrorFactory<T>;
return ErrorFactoryInstance as ErrorFactory<Fields>;
};

// ============================================================================
Expand Down
31 changes: 28 additions & 3 deletions packages/errors/src/error/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@

import type { StandardSchemaV1 } from '@standard-schema/spec';

// ============================================================================
// Schema inference
// ============================================================================

/**
* Extracts the field shape from a Standard Schema, defaulting to
* `Record<string, never>` 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> = [S] extends [StandardSchemaV1]
? StandardSchemaV1.InferOutput<S> & Record<string, unknown>
: Record<string, never>;

// ============================================================================
// Types
// ============================================================================
Expand Down Expand Up @@ -86,13 +108,16 @@ export type ErrorInstance<TFields extends Record<string, unknown> = Record<strin
/**
* Full error config for the error() function.
*
* @internal - Type parameter reserved for future Standard Schema type inference
* Mirrors the public `error()` signature for callers that want to
* type a config object separately. The `fields` parameter accepts
* any Standard Schema-compliant validator; the field shape is
* inferred from the schema's output type.
*/
export type ErrorConfig<_T extends Record<string, unknown> = Record<string, unknown>> = {
export type ErrorConfig<S extends StandardSchemaV1 | undefined = undefined> = {
/** 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 */
Expand Down
67 changes: 64 additions & 3 deletions packages/errors/tests/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(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<unknown, unknown>` and
// no useful type information propagates.
const createMockSchema = <Input, Output>(name = 'mock'): StandardSchemaV1<Input, Output> => {
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 = <Input, Output>(name = 'mock'): StandardSchemaV1<Input, Output> => {
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 }),
},
};
};
Expand Down Expand Up @@ -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<unknown, { email: string; age: number }>();
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<string, never> when no fields are provided', () => {
// When `fields` is omitted, the field shape is empty. The factory
// accepts an optional input (Partial<Record<string, never>> 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<S>`.
type EmailOutput = { email: string };
const schema = createTypedMockSchema<unknown, EmailOutput>();
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<EmailOutput>) => ErrorInstance<EmailOutput> = Factory;
void _check;

expect(typeof Factory).toBe('function');
});
});
});
Loading