Skip to content

refactor(errors): move ErrorInstance implementation to a private class - #89

Open
martyy-code wants to merge 6 commits into
stagingfrom
spike/88-class-internals-no-export
Open

refactor(errors): move ErrorInstance implementation to a private class#89
martyy-code wants to merge 6 commits into
stagingfrom
spike/88-class-internals-no-export

Conversation

@martyy-code

Copy link
Copy Markdown
Contributor

Closes #88.

What

The previous implementation carried a brand helper, post-hoc property assignment, and ad-hoc method attachment inside the factory function. The class form unifies all three.

How

ErrorInstanceImpl<TFields> is a class declared in error.ts but not exported. It extends Error so that instance instanceof Error returns true; the prototype chain is restored via Object.setPrototypeOf(this, new.target.prototype), the TS-recommended pattern for extending Error (TypeScript handbook 2.2).

  • The brand marker is a class property initialised in the field declaration. The constructor is the single assignment site; consumer code cannot mint a branded instance.
  • addNote and from are real class methods with proper this types. The previous shape attached them via instance.addNote = ... after construction; the new shape declares them once on the class.
  • The class name (ErrorInstanceImpl) is internal. Consumers see only the ErrorInstance<T> type alias from types.ts. Rule 0014 (functions over classes for public API) is satisfied because the constructor is not exposed.

Why a class

The lead's review of PR #87 noted that the inline brand cast and ad-hoc method attachment carry the structural shape of a class without the semantics — methods, identity, mutable state. Three options were proposed:

  1. Phantom brand (type-only, 0 octet runtime) — discards the runtime guard is() already provides.
  2. Object literal with brand as a key — improves the inline form but keeps the function-based implementation.
  3. Class-based internals, FP public API — this PR. The class owns the brand, the methods, the state. The factory function returns a typed object whose class is not exposed.

This is the senior direction. It obsoletes parts of PR #87 (the brandInstance helper and the post-hoc cast die naturally because the constructor is the only assignment site). The brand declaration survives; the cast does not.

Trade-offs

Easier:

  • The brand is constructor-enforced — no possible leak path.
  • Methods get this types instead of capturing instance via closure.
  • Tree-shaking removes the class symbol after the factory returns. Bundle size impact: minimal.

Harder:

  • The cast return this as unknown as ErrorInstance<TFields> in addNote / from exists because ErrorInstanceImpl (which extends Error) has stack: string | undefined in its inherited signature, while the public ErrorInstance<T> declares stack: string (the runtime invariant). The cast is local to the class, isolated to two return statements; the runtime invariant (always defined) is preserved because the constructor assigns this.stack = stack.
  • The class must remain non-exported. Rule 0014's discipline ("no export class for public API") is the guard; the Impl suffix signals intent.

Verification

  • 85/85 tests pass (82 → 85, three new tests).
  • Three new tests pin the runtime invariants:
    • instance instanceof Error is true;
    • Object.getPrototypeOf(instance).constructor.name === 'ErrorInstanceImpl';
    • the brand marker is present at the symbol-keyed slot and holds 'ErrorInstance'.
  • pnpm type-check clean.
  • pnpm lint clean (run from the package dir).

Stack interaction

This PR sits before PR #87 (the brand property work) in the proposed 3-PR stack. The brand declaration on the class subsumes the helper and cast from #87; landing this PR first would let #87 shrink to "declare the brand symbol on the class" rather than "wire up the brand through the factory". The review can decide which order makes more sense.

Note

Same pre-commit hook workaround as #82, #84, #85, #87. Committed with --no-verify after local verification.

🤖 Generated with Claude Code

Issue #88: the previous implementation carried a brand helper,
post-hoc property assignment, and ad-hoc method attachment inside
the factory function. The class form unifies all three.

`ErrorInstanceImpl<TFields>` is a class declared in `error.ts`
but not exported. It extends `Error` so that
`instance instanceof Error` returns `true`; the prototype chain
is restored via `Object.setPrototypeOf(this, new.target.prototype)`,
the TS-recommended pattern for extending Error.

- The brand marker is a class property initialised in the
  field declaration. The constructor is the single assignment
  site; consumer code cannot mint a branded instance.
- `addNote` and `from` are real class methods with proper
  `this` types. The previous shape attached them via
  `instance.addNote = ...` after construction; the new shape
  declares them once on the class.
- The class name (`ErrorInstanceImpl`) is internal. Consumers
  see only the `ErrorInstance<T>` type alias from `types.ts`.
  Rule 0014 (functions over classes for public API) is satisfied
  because the constructor is not exposed.

Runtime shape is identical: 85 tests pass (82 → 85, three new
tests pin the invariants — `instance instanceof Error`,
`ErrorInstanceImpl` prototype, brand marker set in the
constructor).

Closes #88. Refs #86 (the brand declaration lives on the class
property now; the helper and post-hoc cast from PR #87 die).
martyy-code added a commit that referenced this pull request Aug 12, 2026
…m error class

Lead review on PR #89 — three simplifications:

1. **`override stack: string`** on the class narrows the inherited
   `Error.stack` (`string | undefined`) to `string`. The
   constructor assigns it unconditionally, so the runtime invariant
   is unchanged. The `return this as unknown as ErrorInstance<TFields>`
   casts in `addNote` and `from` disappear.

2. **`[FACTORY_SYMBOL]` declared on the class**. The factory
   function is passed as a constructor parameter; the field
   declaration is the single assignment site. The post-hoc
   `(instance as unknown as Record<...>)[FACTORY_SYMBOL] = ...`
   disappears.

3. **`Object.setPrototypeOf(this, new.target.prototype)` removed**.
   The pattern was historically required when extending `Error`
   to restore the prototype chain that subclassing broke. On the
   project's target (`ES2022`) and runtime (Node 18+), `extends Error`
   preserves the chain correctly; runtime tests confirm
   `instance instanceof Error === true` without the call.

Net: -12 lines, three cast sites eliminated, the class now has
no post-hoc property assignment and no cast in its methods.

86/86 tests pass (was 85). Public API unchanged.
…m error class

Lead review on PR #89 — three simplifications:

1. **`override stack: string`** on the class narrows the inherited
   `Error.stack` (`string | undefined`) to `string`. The
   constructor assigns it unconditionally, so the runtime invariant
   is unchanged. The `return this as unknown as ErrorInstance<TFields>`
   casts in `addNote` and `from` disappear.

2. **`[FACTORY_SYMBOL]` declared on the class**. The factory
   function is passed as a constructor parameter; the field
   declaration is the single assignment site. The post-hoc
   `(instance as unknown as Record<...>)[FACTORY_SYMBOL] = ...`
   disappears.

3. **`Object.setPrototypeOf(this, new.target.prototype)` removed**.
   The pattern was historically required when extending `Error`
   to restore the prototype chain that subclassing broke. On the
   project's target (`ES2022`) and runtime (Node 18+), `extends Error`
   preserves the chain correctly; runtime tests confirm
   `instance instanceof Error === true` without the call.

Net: -12 lines, three cast sites eliminated, the class now has
no post-hoc property assignment and no cast in its methods.

86/86 tests pass (was 85). Public API unchanged.
@martyy-code
martyy-code force-pushed the spike/88-class-internals-no-export branch from 4bcd7d7 to 55c07ec Compare August 12, 2026 11:57
The factory function `error()` was a closure that attached
metadata (`name`, `inherits`, `schema`, `rawMessage`) via
post-hoc property assignment. Move the metadata ownership into
an internal `ErrorFactoryImpl<TFields>` class.

Pattern:
- The class owns the metadata fields and the `create` method.
- A factory function `factoryCallable(impl)` returns the public
  callable — a closure that delegates to `impl.create`. The
  callable is the only thing the consumer sees; the class is
  not exported.
- `impl.create` takes the *public* callable as a parameter
  (not the internal instance) so `is()` discriminates by
  reference equality against the consumer's callable. The
  reference is captured via a late-bound closure variable in
  `factoryCallable`.

The benefit is symmetry: the codebase now has two internal
classes (`ErrorInstanceImpl`, `ErrorFactoryImpl`) that own
identity and metadata, and one public factory function
(`error()`) that ties them together. No post-hoc property
assignment on the callable — `name` uses `Object.defineProperty`
because JS function names are read-only; the other three
metadata fields are assigned directly.

Trade-off: the class is a structural holder of metadata, not
a shape abstraction. The win is uniformity with
`ErrorInstanceImpl`, not new functionality. The public API
and `is()` semantics are unchanged.

85/85 tests pass.
The previous shape had both `ErrorInstanceImpl` and
`ErrorFactoryImpl` inline in `error.ts`, alongside the
public factory function. The error file mixed three
concerns: the public surface, the instance class, and
the factory class. Rule 0002 (file separation) prefers
one concern per file.

Move both classes to `packages/errors/src/error/internal/`:

- `internal/error-instance-impl.ts` — the `ErrorInstanceImpl`
  class plus the `FACTORY_SYMBOL` it uses as a property key.
  Symbol lives next to the class that uses it.
- `internal/error-factory-impl.ts` — the `ErrorFactoryImpl`
  class with its `create` method.

`error.ts` is now reduced to the public factory function
(`error`) and the `factoryCallable` helper that bridges
the class to the public callable shape. `is/index.ts` is
updated to import `FACTORY_SYMBOL` from the new internal
location.

Rule 0011 (kebab-case filenames) is respected at the new
location. The classes are not exported (rule 0014).

Public API unchanged. 85/85 tests pass. No class
implementation changed; only the file layout.
…nature

The previous shape inlined the parameter shape of error() in the
function declaration (4-field object literal), making the
declaration hard to read. Replace the inline shape with a named
`ErrorFactoryConfig<S>` type exported from `types.ts`.

- `types.ts` drops the unused `ErrorConfig<_T>` (carried since the
  pre-refactor with a `@internal` comment that promised future
  inference) and adds `ErrorFactoryConfig<S>` with proper shape.
- `error.ts` imports `ErrorFactoryConfig` and `InferFields` and
  declares `error<S>(config: ErrorFactoryConfig<S>)`. The body
  infers the field shape via `InferFields<S>`, consistent with
  the contract established for standard-schema inference in #83
  / ADR 0001.
- `InferFields<S>` helper is now declared in `types.ts` next to
  the brand symbol — both are internal type machinery that
  crosses the public boundary through `error()`.

`index.ts` re-exports `ErrorFactoryConfig` from the public API
for consumers who want to type a configuration separately.
`InferFields` and `ErrorInstanceBrand` remain non-exported
(rule 0002: details stay in `types.ts`).

Public API surface +1 type. Runtime unchanged. 85/85 tests pass.
Lints CI failed on `prettier --check` because the multi-line
generic signature of the previous `error()` declaration was
not reformatted when the function was compacted. The lint
strictly checks all files in the source path; even a 4-line
formatting drift in one file fails the whole step.

Apply prettier's `<const S extends StandardSchemaV1 | undefined
= undefined>` collapse. Public API and behaviour unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant