Skip to content

[Refactor]: Brand ErrorInstance so the type can be trusted at function boundaries #86

Description

@martyy-code

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:

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:

  1. 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.
  2. Land the brand. is() becomes error is ErrorInstance<...>, narrowing works at the boundary, public functions shrink their parameters.
  3. Land follow-up cleanups: causes() drops the structural guard, is() drops its try/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:

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):

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.stringifyJSON.parse). Mitigation: the brand does not survive JSON; consumers who need round-tripping must use a custom serializer that re-mints the brand.

Related

Effort and priority

  • Effort: m - 1-2 days for the brand + test coverage. The cleanup PRs each add s - 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-audit if 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)

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