You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 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.
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.
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
Effort:s - Half a day to m - 1-2 days depending on how much cleanup of the surrounding signatures we take on.
Backward compatibility: the public API shape does not change (the fields parameter still accepts a StandardSchemaV1); 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 a minor bump (this is a new capability, not a bug fix).
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 Schema choice is a deliberate assumption. This PR is the operational form of that assumption.
Problem
packages/errors/src/error/error.ts:65declareserror()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: theconfig.fieldsparameter accepts aStandardSchemaV1(an opaque, uniformly-typed value), soTis always resolved to the defaultRecord<string, never>at every call site.This creates three downstream smells:
<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.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.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/specdependency is inpackage.jsonprecisely 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:
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.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.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
~standardso 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.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.ErrorConfigJSDoc intypes.ts:91-99no longer says "reserved for future".Effort and risk
s - Half a daytom - 1-2 daysdepending on how much cleanup of the surrounding signatures we take on.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.fieldsparameter still accepts aStandardSchemaV1); only the type consumers receive back is more precise. No consumer code should break.Plan
staging. Track as aminorbump (this is a new capability, not a bug fix).ErrorFactoryInstance) on the cleaner foundation. The trailing cast will already be gone; the remaining work is naming and decomposition.Related
@standard-schema/specdependency. The ADR documents the contract; this issue delivers on it.ErrorFactoryInstance. Once inference lands, the remaining work inerror.tsis naming and decomposition, not type plumbing.FACTORY_SYMBOL. That fix is the foundation this PR builds on.error().Standard Schemachoice is a deliberate assumption. This PR is the operational form of that assumption.