Repository navigation
Description
Problem
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).
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.- Public functions that take
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 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:
declare const ErrorInstanceBrand: unique symbol;
export type ErrorInstance<TFields extends Record<string, unknown> = Record<string, never>> =
ErrorInstanceCore & {
readonly [ErrorInstanceBrand]: 'ErrorInstance';
// ... existing fields ...
};
export const error = <const S extends StandardSchemaV1 | undefined = undefined>(
config: { ... }
): ErrorFactory<InferFields<S>> => {
// The factory is the only place the brand is attached.
const ErrorFactoryInstance = (input?: Partial<InferFields<S>>): ErrorInstance<InferFields<S>> => {
const instance = new Error(errorMessage) as ErrorInstance<InferFields<S>>;
(instance as { readonly [ErrorInstanceBrand]: 'ErrorInstance' })[ErrorInstanceBrand] = 'ErrorInstance';
// ...
};
};Once branded, every public function can narrow its input. causes() becomes:
const causes = (error: ErrorInstance<unknown>): Error[] => {
return error.causes;
};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()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. - Land the brand.
is()becomeserror is ErrorInstance<...>, narrowing works at the boundary, public functions shrink their parameters. - Land follow-up cleanups:
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>.- The structural guards in PR fix(errors): replace cast in causes/index.ts with a structural guard #85 and the redundant guards in PR [Refactor]: Remove redundant typeof/null guard after narrowing in is/index.ts #73 become provably dead code. Net code reduction.
- 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, 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). - 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. - PR (middle) — implement
is()predicate (issue [BUG] is() should return TypeScript type predicate for narrowing #35). Without this, the brand is unreachable for consumers. - PR (top) — shrink public function parameters, retire dead guards (PR fix(errors): replace cast in causes/index.ts with a structural guard #85's structural guard, PR [Refactor]: Remove redundant typeof/null guard after narrowing in is/index.ts #73's redundant guard, PR [Refactor]: Remove silent try/catch around instanceof in is/index.ts #72's silent catch).
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 afromObject<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. 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
- [BUG] is() should return TypeScript type predicate for narrowing #35 —
is()should return a TypeScript type predicate. Precondition for the brand to be useful. - [Refactor]: Replace cast in causes/index.ts with a structural guard #74 —
causes()structural guard (PR fix(errors): replace cast in causes/index.ts with a structural guard #85). Becomes dead code once the brand lands. - [Refactor]: Remove silent try/catch around instanceof in is/index.ts #72 / [Refactor]: Remove redundant typeof/null guard after narrowing in is/index.ts #73 — silent catch and redundant guard in
is(). Become dead code onceis()returns a predicate. - [Refactor]: Decompose and rename ErrorFactoryInstance in error.ts #77 — Decompose and rename
ErrorFactoryInstance. Independent refactor; the brand lands more cleanly on a renamed function. - rules/0015-domain-specific-types.md — the brand is rule 0015 in operational form: the type carries the identity the domain cares about.
- rules/0004-no-speculative-defences.md — the brand is the type-level defence the rule asks for at runtime boundaries.
- rules/0008-no-chained-type-assertions.md — the brand eliminates the need for
as ErrorInstancecasts at the consumer side.
Effort and priority
- Effort:
m - 1-2 daysfor the brand + test coverage. The cleanup PRs each adds - Half a day. - Priority:
p1 - High. The brand is the precondition for several other cleanup PRs and the senior direction the package has been heading. - Milestone:
arch-rules-auditif the cleanup is bundled; otherwise a new milestone for the type-system work.
Pre-Submission Checklist
- I have searched existing issues for related refactoring requests
- Risks and migration plan are documented
- Test coverage approach is defined
- I understand this issue will be labeled according to the project taxonomy
- This is NOT a security vulnerability (see security note above)
Activity