Repository navigation
Description
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:
- The signature lies.
<const T extends ...>promises literal-type inference that the contract does not deliver. Consumers writingerror({ name: 'X', fields: z.object({ email: z.string() }) })get no type information back — the resultingErrorInstance<T>isErrorInstance<Record<string, never>>, equivalent to no fields at all. - A trailing cast is required to satisfy the declared return type.
return ErrorFactoryInstance as ErrorFactory<T>;(line 145) exists only because the internalErrorFactoryInstancecannot be constructed with the rightTto 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. - The
ErrorConfigtype intypes.ts:91-99documents this with@internal - Type parameter reserved for future Standard Schema type inferenceand 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.tsstays, the JSDoc says "reserved for future" forever, and every PR that toucheserror()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/specpays 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()acceptsfields: StandardSchemaV1and usesStandardSchemaV1.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
ErrorConfigJSDoc intypes.ts:91-99no 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
- Effort:
s - Half a daytom - 1-2 daysdepending on how much cleanup of the surrounding signatures we take on. - Risk: low for the inference change itself; medium if we use the opportunity to also fix the
ErrorFactoryInstancenaming/decomposition (see [Refactor]: Decompose and rename ErrorFactoryInstance in error.ts #77). Recommend landing inference alone first, then revisiting [Refactor]: Decompose and rename ErrorFactoryInstance in error.ts #77 in a follow-up. - Backward compatibility: the public API shape does not change (the
fieldsparameter still accepts aStandardSchemaV1); only the type consumers receive back is more precise. No consumer code should break.
Plan
- Land the inference refactor in a single PR targeting
staging. Track as aminorbump (this is a new capability, not a bug fix). - 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".
- 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
- [Docs]: Add ADR for the @standard-schema/spec dependency choice #80 — ADR for the
@standard-schema/specdependency. The ADR documents the contract; this issue delivers on it. - [Refactor]: Decompose and rename ErrorFactoryInstance in error.ts #77 — decompose and rename
ErrorFactoryInstance. Once inference lands, the remaining work inerror.tsis naming and decomposition, not type plumbing. - [Refactor]: Replace chained cast in error.ts:130 with a single cast on the declared type #71 (just shipped) — replaced the chained cast on
FACTORY_SYMBOL. That fix is the foundation this PR builds on. - docs/engineering/architecture/rules/0015-domain-specific-types.md — rule 0015 requires domain concepts to be domain types. The schema's output is the domain type; this PR is the rule 0015 application to
error(). - docs/engineering/architecture/rules/0006-technology-choices.md — the
Standard Schemachoice is a deliberate assumption. This PR is the operational form of that assumption.
Activity
docs(arch): add ADR 0001 for Standard Schema as runtime validation co…
refactor(errors): extract ErrorFactoryConfig type for the error() sig…
feat(errors): implement Standard Schema type inference in error()
docs(arch): add ADR 0001 for Standard Schema as runtime validation co…
docs(arch): add ADR 0001 for Standard Schema as runtime validation co…
feat(errors): implement Standard Schema type inference in error() (#84)