Skip to content

feat(errors): implement Standard Schema type inference in error() - #84

Open
martyy-code wants to merge 2 commits into
stagingfrom
feat/83-standard-schema-inference
Open

feat(errors): implement Standard Schema type inference in error()#84
martyy-code wants to merge 2 commits into
stagingfrom
feat/83-standard-schema-inference

Conversation

@martyy-code

@martyy-code martyy-code commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #83.

What

error() declared a <const T extends Record<string, unknown>> placeholder parameter that was never inferred from any source. The fields: StandardSchemaV1 parameter was opaque, so T always fell back to the default Record<string, never> at every call site. The signature promised inference; the contract did not deliver it.

How

  • Replace T with a schema-derived S extends StandardSchemaV1 | undefined in error.ts:76.
  • Add an InferFields<S> helper in types.ts that uses StandardSchemaV1.InferOutput<S>, intersected with Record<string, unknown> to satisfy the ErrorFactory<TFields> constraint. The intersection is transparent at the call site — the consumer sees the precise shape, not the intersection.
  • When fields is omitted, InferFields<undefined> falls back to Record<string, never> (existing default behaviour preserved).
  • Update ErrorConfig to mirror the public signature.
  • The trailing cast return ErrorFactoryInstance as ErrorFactory<Fields> is preserved (single cast, single boundary, rule 0008 compliant) but the gap it bridges is narrower: the cast sits only at the metadata-attachment boundary, not at the type-parameter boundary.

Consumer impact

Before:

const ValidationError = error<{ email: string; age: number }>({
  name: "ValidationError",
  fields: z.object({ email: z.string(), age: z.number() }),
});

After:

const ValidationError = error({
  name: "ValidationError",
  fields: z.object({ email: z.string(), age: z.number() }),
});
// ValidationError accepts exactly { email, age } — no annotation.

Verification

  • 85/85 tests pass (82 → 85, three new inference regressions).
  • Three new tests prove the inference at compile time: if InferFields<S> regressed, the test bodies would fail to type-check.
  • pnpm type-check clean.
  • pnpm lint clean (run from the package dir).

Follow-up commit

This PR also includes docs/engineering/architecture/decisions/0001-standard-schema-for-runtime-validation.md (ADR 0001) which captures the architectural rationale for the inference choice. Closes #80.

Note

The pre-commit eslint --fix hook at the monorepo root is still broken (no eslint.config.js at root, only at packages/errors/). I committed with --no-verify after confirming the code passes prettier + eslint from the package dir. Same flag as #82.

🤖 Generated with Claude Code

Issue #83: `error()` declared a `<const T extends Record<string,
unknown>>` placeholder parameter that was never inferred from any
source — the `fields: StandardSchemaV1` parameter was opaque, so
`T` always fell back to the default `Record<string, never>` 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<S>` helper in `types.ts`
extracts the output type via `StandardSchemaV1.InferOutput<S>`,
intersected with `Record<string, unknown>` to satisfy the
`ErrorFactory<TFields>` constraint. When `fields` is omitted,
`InferFields<undefined>` falls back to `Record<string, never>`
(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<S>` propagates.

Closes #83.

85/85 tests pass (82 → 85, three new inference regressions).
Type-check clean. Lint clean (run from the package dir).
…ntract

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<S>
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant