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/types.ts declares ErrorInstance<TFields> as a structural shape that TypeScript satisfies by duck typing. Any object with the right fields — a literal { name, message, stack, fields, notes, cause, causes }, a native Error with .causes grafted on manually, an object from a foreign library that happens to match — is assignable to ErrorInstance<TFields> without ceremony.
This is the smell that prevents the senior move: shrinking public function signatures from unknown to ErrorInstance<T> to "trust the type" at the boundary. The slogan of rule 0001 is "if the type says it's not null, trust the type". The corollary is if the type cannot back up what it claims, do not trust it.ErrorInstance<T> cannot back up the claim "this object is an error produced by error()", so functions like causes() rightly take unknown and structural-guard the input (PR #85, issue #74).
is() returns boolean (issue [BUG] is() should return TypeScript type predicate for narrowing #35), so consumers cannot narrow from unknown to ErrorInstance<T> at the call site. They are forced to either pass unknown through, or write their own narrowing with as ErrorInstance.
Public functions that take error: unknown repeat the same guard pattern. There are at least two today (is(), causes()); there will be more as the library grows.
Recommended direction
Brand ErrorInstance<TFields> so that the type system can prove the value came from error(). The brand is set by the factory and only the factory can set it. Every other path (cast, literal, foreign shape) is refused by TS.
Concretely:
declareconstErrorInstanceBrand: unique symbol;exporttypeErrorInstance<TFieldsextendsRecord<string,unknown>=Record<string,never>>=ErrorInstanceCore&{readonly[ErrorInstanceBrand]: 'ErrorInstance';// ... existing fields ...};exportconsterror=<constSextendsStandardSchemaV1|undefined=undefined>(config: { ... }): ErrorFactory<InferFields<S>>=>{// The factory is the only place the brand is attached.constErrorFactoryInstance=(input?: Partial<InferFields<S>>): ErrorInstance<InferFields<S>>=>{constinstance=newError(errorMessage)asErrorInstance<InferFields<S>>;(instanceas{readonly[ErrorInstanceBrand]: 'ErrorInstance'})[ErrorInstanceBrand]='ErrorInstance';// ...};};
Once branded, every public function can narrow its input. causes() becomes:
Native Error instances passed to causes() become a type error at the call site, not a silent runtime fall-through. The structural guard from PR #85 becomes dead code that the compiler can prove dead.
Dependencies and sequencing
This issue depends on issue #35 (is() should return a TypeScript type predicate for narrowing). The sequencing matters:
Land [BUG] is() should return TypeScript type predicate for narrowing #35 first. Without a predicate, is() returns boolean and consumers cannot bridge unknown → ErrorInstance at the call site. Branding without a predicate leaves the consumer stuck — they have a narrower type that they cannot reach.
Land the brand. is() becomes error is ErrorInstance<...>, narrowing works at the boundary, public functions shrink their parameters.
If the brand lands without #35 first, the consumer code path is try { ... } catch (err) { causes(err as ErrorInstance) } — which restores the cast problem this issue is trying to solve.
Trade-offs
Easier:
causes(), is(), and every future consumer-side function can shrink their parameter type from unknown to ErrorInstance<T>.
Consumers can write try { ... } catch (err) { if (is(err, X)) { causes(err) } } and TS proves the narrowing.
Foreign values (cross-realm, deserialized, third-party) cannot masquerade as ErrorInstance. The brand is the defense — and it is rule 0004 in operational form: the type is the guard, no runtime check needed.
Harder:
The brand is a one-way door: every existing consumer code path that assigns a duck-typed object to ErrorInstance<T> breaks at compile time. Audit needed before the bump.
The brand survives JSON.parse, Object.assign, and structuredClone only if those operations preserve the brand symbol. The brand must be a unique symbol, not a string; symbols do not survive JSON serialization, which is the correct behaviour (a serialized error is no longer a brand-validated instance).
The brand has to be set in exactly one place: error(). If two factories set the same brand, they are indistinguishable from each other. The library's design enforces one factory per error type, so this is not a regression — but it's a property to document.
Suggested PR stack
This is a single PR in the stack, but it touches multiple concerns and benefits from the stacked-PR pattern (see docs/learnings/github/stacked-pr):
PR (bottom) — add the brand symbol + type, attach it in error(). Pure addition; runtime unchanged; no consumer code broken yet.
Three PRs, bottom-up, each independently reviewable. Each assumes the previous.
Revisit conditions
This issue should be revisited when:
A consumer reports that a duck-typed object is being assigned to ErrorInstance<T> and the brand blocks them. Mitigation: provide a fromObject<T>(obj): ErrorInstance<T> escape hatch for the documented duck-typed case (e.g. bridging from a foreign error library).
The brand symbol is accidentally exported and TS allows external code to mint instances. Mitigation: keep the symbol internal; only error() writes it.
ErrorInstance<T> shows up as a serialised payload (e.g. via JSON.stringify → JSON.parse). Mitigation: the brand does not survive JSON; consumers who need round-tripping must use a custom serializer that re-mints the brand.
Problem
packages/errors/src/error/types.tsdeclaresErrorInstance<TFields>as a structural shape that TypeScript satisfies by duck typing. Any object with the right fields — a literal{ name, message, stack, fields, notes, cause, causes }, a nativeErrorwith.causesgrafted on manually, an object from a foreign library that happens to match — is assignable toErrorInstance<TFields>without ceremony.This is the smell that prevents the senior move: shrinking public function signatures from
unknowntoErrorInstance<T>to "trust the type" at the boundary. The slogan of rule 0001 is "if the type says it's not null, trust the type". The corollary is if the type cannot back up what it claims, do not trust it.ErrorInstance<T>cannot back up the claim "this object is an error produced byerror()", so functions likecauses()rightly takeunknownand structural-guard the input (PR #85, issue #74).The downstream cost of this gap is real:
causes(error: unknown)(the post-[Refactor]: Replace cast in causes/index.ts with a structural guard #74 shape) requires a structural guard at runtime. The guard is correct but redundant once the type can do the same job.is()returnsboolean(issue [BUG] is() should return TypeScript type predicate for narrowing #35), so consumers cannot narrow fromunknowntoErrorInstance<T>at the call site. They are forced to either passunknownthrough, or write their own narrowing withas ErrorInstance.error: unknownrepeat the same guard pattern. There are at least two today (is(),causes()); there will be more as the library grows.Recommended direction
Brand
ErrorInstance<TFields>so that the type system can prove the value came fromerror(). The brand is set by the factory and only the factory can set it. Every other path (cast, literal, foreign shape) is refused by TS.Concretely:
Once branded, every public function can narrow its input.
causes()becomes:Native
Errorinstances passed tocauses()become a type error at the call site, not a silent runtime fall-through. The structural guard from PR #85 becomes dead code that the compiler can prove dead.Dependencies and sequencing
This issue depends on issue #35 (
is()should return a TypeScript type predicate for narrowing). The sequencing matters:is()returnsbooleanand consumers cannot bridgeunknown → ErrorInstanceat the call site. Branding without a predicate leaves the consumer stuck — they have a narrower type that they cannot reach.is()becomeserror is ErrorInstance<...>, narrowing works at the boundary, public functions shrink their parameters.causes()drops the structural guard,is()drops itstry/catch(PR [Refactor]: Remove silent try/catch around instanceof in is/index.ts #72),is()drops the redundant guard (PR [Refactor]: Remove redundant typeof/null guard after narrowing in is/index.ts #73).If the brand lands without #35 first, the consumer code path is
try { ... } catch (err) { causes(err as ErrorInstance) }— which restores the cast problem this issue is trying to solve.Trade-offs
Easier:
causes(),is(), and every future consumer-side function can shrink their parameter type fromunknowntoErrorInstance<T>.try { ... } catch (err) { if (is(err, X)) { causes(err) } }and TS proves the narrowing.ErrorInstance. The brand is the defense — and it is rule 0004 in operational form: the type is the guard, no runtime check needed.Harder:
ErrorInstance<T>breaks at compile time. Audit needed before the bump.JSON.parse,Object.assign, andstructuredCloneonly if those operations preserve the brand symbol. The brand must be aunique symbol, not a string; symbols do not survive JSON serialization, which is the correct behaviour (a serialized error is no longer a brand-validated instance).error(). If two factories set the same brand, they are indistinguishable from each other. The library's design enforces one factory per error type, so this is not a regression — but it's a property to document.Suggested PR stack
This is a single PR in the stack, but it touches multiple concerns and benefits from the stacked-PR pattern (see
docs/learnings/github/stacked-pr):error(). Pure addition; runtime unchanged; no consumer code broken yet.is()predicate (issue [BUG] is() should return TypeScript type predicate for narrowing #35). Without this, the brand is unreachable for consumers.Three PRs, bottom-up, each independently reviewable. Each assumes the previous.
Revisit conditions
This issue should be revisited when:
ErrorInstance<T>and the brand blocks them. Mitigation: provide afromObject<T>(obj): ErrorInstance<T>escape hatch for the documented duck-typed case (e.g. bridging from a foreign error library).error()writes it.ErrorInstance<T>shows up as a serialised payload (e.g. viaJSON.stringify→JSON.parse). Mitigation: the brand does not survive JSON; consumers who need round-tripping must use a custom serializer that re-mints the brand.Related
is()should return a TypeScript type predicate. Precondition for the brand to be useful.causes()structural guard (PR fix(errors): replace cast in causes/index.ts with a structural guard #85). Becomes dead code once the brand lands.is(). Become dead code onceis()returns a predicate.ErrorFactoryInstance. Independent refactor; the brand lands more cleanly on a renamed function.as ErrorInstancecasts at the consumer side.Effort and priority
m - 1-2 daysfor the brand + test coverage. The cleanup PRs each adds - Half a day.p1 - High. The brand is the precondition for several other cleanup PRs and the senior direction the package has been heading.arch-rules-auditif the cleanup is bundled; otherwise a new milestone for the type-system work.Pre-Submission Checklist