Skip to content

[Refactor]: Implement Standard Schema type inference in error() to retire the dead <const T> placeholder #83

Description

@martyy-code

Problem

packages/errors/src/error/error.ts:65 declares error() with a <const T extends Record<string, unknown> = Record<string, never>> type parameter. The parameter is never inferred from any source in the public API: the config.fields parameter accepts a StandardSchemaV1 (an opaque, uniformly-typed value), so T is always resolved to the default Record<string, never> at every call site.

This creates three downstream smells:

  1. The signature lies. <const T extends ...> promises literal-type inference that the contract does not deliver. Consumers writing error({ name: 'X', fields: z.object({ email: z.string() }) }) get no type information back — the resulting ErrorInstance<T> is ErrorInstance<Record<string, never>>, equivalent to no fields at all.
  2. A trailing cast is required to satisfy the declared return type. return ErrorFactoryInstance as ErrorFactory<T>; (line 145) exists only because the internal ErrorFactoryInstance cannot be constructed with the right T to match the declared return. The cast is single (so it does not violate rule 0008), but it is a smell — it bridges a gap that the signature should close on its own.
  3. The ErrorConfig type in types.ts:91-99 documents this with @internal - Type parameter reserved for future Standard Schema type inference and prefixes the parameter with _. The author flagged the gap; the gap was never closed.

The @standard-schema/spec dependency is in package.json precisely to enable this inference. We are paying for the contract without collecting on it.

Recommended direction (senior position)

Implement Standard Schema type inference now. This is the only option that survives the next six months without compounding debt:

  • Path A (do nothing, freeze T = Record<string, never>): the lib is permanently not a type-safe error wrapper. It looks like one (function-based API, Standard Schema dependency) but is not. The README and the dependency lie.
  • Path B (keep the placeholder, defer): the cast at the end of error.ts stays, the JSDoc says "reserved for future" forever, and every PR that touches error() has to navigate the lie. This is the standard "we'll do it in v2" pattern; v2 does not come.
  • Path C (implement inference): the signature is refactored to accept a typed schema and propagate its inferred output to the ErrorInstance<T>. The cast at the end dies naturally. The dependency on @standard-schema/spec pays for itself. The lib delivers on the promise its README and dependency already make.

The Standard Schema spec exists exactly for this case. Zod, Valibot, and ArkType all expose ~standard so a library like ours can infer types without picking a validator. Not using the contract is buying the dependency and not cashing the dividend.

Proposed state

After this work:

  • error() accepts fields: StandardSchemaV1 and uses StandardSchemaV1.InferOutput<typeof fields> (the spec's official helper) to derive the field type.
  • The trailing cast return ErrorFactoryInstance as ErrorFactory<T> is removed; the internal construction is correctly typed end-to-end.
  • <const T extends Record<string, unknown> = Record<string, never>> is replaced by a type parameter that propagates the schema's output.
  • The ErrorConfig JSDoc in types.ts:91-99 no longer says "reserved for future".
  • Tests cover all three validators (Zod, Valibot, ArkType) to confirm inference works across the spec.
  • The README and JSDoc are updated to advertise the inference as a feature.

Effort and risk

Plan

  1. Land the inference refactor in a single PR targeting staging. Track as a minor bump (this is a new capability, not a bug fix).
  2. Cross-link the ADR ([Docs]: Add ADR for the @standard-schema/spec dependency choice #80) in the PR description — the ADR is the "why"; this issue is the "how".
  3. After landing, re-evaluate [Refactor]: Decompose and rename ErrorFactoryInstance in error.ts #77 (decompose and rename ErrorFactoryInstance) on the cleaner foundation. The trailing cast will already be gone; the remaining work is naming and decomposition.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    p1: highRequired for next releasetype: refactorRefactoring / code restructuring

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions