From fde15fec0bacdf38792a06035d3b0b962c3b4cd2 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Wed, 12 Aug 2026 11:59:48 +0200 Subject: [PATCH 1/2] feat(errors): brand ErrorInstance so the type can be trusted at boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #86: `ErrorInstance` was structurally typed — any object with the right fields (a literal, a foreign shape, a native Error with `.causes` grafted on) was assignable to it. The type could not back up the claim "this object is an error produced by `error()`", which kept every public function honest about `unknown` (PR #85's structural guard, PR #72/#73's defensive checks). Brand `ErrorInstance` with a `unique symbol`: - `ErrorInstanceBrand` is created and exported from `types.ts` as `Symbol('@deessejs/errors/brand')`. The symbol is not registered in the global registry (unlike `FACTORY_SYMBOL`) so it cannot collide with code that adopts the same convention by accident. - The brand property is declared `readonly` on `ErrorInstance`. The only assignment site is `error()` itself; consumer code cannot mint a branded instance without an `as` escape hatch. - Object literals and native `Error` instances are now refused at the type level — the brand is the operational form of rule 0004 (the type is the guard) and rule 0015 (domain types over primitives; the domain identity of an `ErrorInstance` is "produced by `@deessejs/errors`"). This unlocks the senior direction called out in #35: once `is()` returns a type predicate and the brand exists, consumers can narrow `unknown` to `ErrorInstance` at the boundary and trust the type at every call site. The brand alone is the bottom of the proposed stack (brand → predicate → shrink public function parameters). Three new tests in `error.test.ts` pin the invariant: - an instance produced by the factory carries two symbol-keyed properties (`FACTORY_SYMBOL` and `ErrorInstanceBrand`); the brand slot holds the literal `'ErrorInstance'`; - a plain object literal cannot be assigned to `ErrorInstance` without the brand (`@ts-expect-error`); - a native `Error` has no symbol-keyed properties and cannot satisfy `ErrorInstance` at the type level. Closes #86. Refs #35 (predicate is the precondition for the brand to be useful at the consumer side). --- .changeset/feat-86-brand-error-instance.md | 11 ++++ packages/errors/src/error/error.ts | 10 +++ packages/errors/src/error/types.ts | 28 ++++++++ packages/errors/tests/error.test.ts | 74 ++++++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 .changeset/feat-86-brand-error-instance.md diff --git a/.changeset/feat-86-brand-error-instance.md b/.changeset/feat-86-brand-error-instance.md new file mode 100644 index 0000000..bdac690 --- /dev/null +++ b/.changeset/feat-86-brand-error-instance.md @@ -0,0 +1,11 @@ +--- +"@deessejs/errors": minor +--- + +Brand `ErrorInstance` with a `unique symbol` so the type system can prove the value came from `error()`. The brand is set on every instance at construction time (the only assignment site in the codebase) and is declared `readonly` on the type — consumer code cannot assign the brand without an `as` escape hatch, and any duck-typed object that lacks the brand is refused by the compiler. + +The brand is the operational form of rule 0004 (the type is the guard) and rule 0015 (domain types over primitives — the domain identity of an `ErrorInstance` is "produced by `@deessejs/errors`"). It unlocks the senior direction called out in #35: once `is()` returns a type predicate and the brand exists, consumers can narrow `unknown` to `ErrorInstance` at the boundary and trust the type at every call site. + +Public API unchanged at runtime. The brand is opaque: it does not appear in any consumer-facing JSDoc and is not exported as a value from `index.ts`. Three new tests pin the runtime invariant (two symbol-keyed properties on a factory instance, none on a native `Error`) and the compile-time rejection (a literal without the brand is refused at the type level). 85 tests pass. + +Closes #86. Refs #35 (predicate is the precondition for the brand to be useful at the consumer side). \ No newline at end of file diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index 9f420a8..f154ab9 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -7,6 +7,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { ErrorFactory, ErrorInstance } from './types.js'; +import { ErrorInstanceBrand } from './types.js'; import { captureStack } from './capture.js'; import { formatTemplate, hasTemplatePlaceholders } from './format.js'; @@ -107,6 +108,15 @@ export const error = = Record distinguishable from a duck-typed + // object at the type level (see ErrorInstanceBrand in types.ts). + // The cast drops the `readonly` modifier locally so the assignment + // compiles; the runtime invariant (only `error()` sets the brand) + // is enforced by the fact that the brand property is declared + // `readonly` on the type, so consumer code cannot assign it + // without an `as` escape hatch. + (instance as { [ErrorInstanceBrand]: 'ErrorInstance' })[ErrorInstanceBrand] = 'ErrorInstance'; instance.stack = stack; // Add .from() method for exception chaining diff --git a/packages/errors/src/error/types.ts b/packages/errors/src/error/types.ts index 41ab0f6..6ffa3ba 100644 --- a/packages/errors/src/error/types.ts +++ b/packages/errors/src/error/types.ts @@ -4,6 +4,25 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; +// ============================================================================ +// Brand +// ============================================================================ + +/** + * Internal brand symbol used to mark values produced by `error()`. + * The symbol is created and assigned in `error.ts`; this export + * exposes it for the type declaration only (consumers cannot write + * to it because the property is declared `readonly`). + * + * The brand is opaque and `unique`, so the only assignable site is + * `error()` itself. Object literals and foreign shapes cannot + * satisfy `ErrorInstance` at the type level — the brand is the + * operational form of rule 0004: the type is the guard. + * + * @internal + */ +export const ErrorInstanceBrand: unique symbol = Symbol('@deessejs/errors/brand'); + // ============================================================================ // Types // ============================================================================ @@ -39,6 +58,15 @@ export type ErrorFactory = Record = Record> = ErrorInstanceCore & { + /** + * Brand marker. Set by `error()` at construction time; cannot be + * set by any other code path. The brand is what makes + * `ErrorInstance` distinguishable from a duck-typed object at + * the type level — see `ErrorInstanceBrand` for the rationale. + * + * @internal + */ + readonly [ErrorInstanceBrand]: 'ErrorInstance'; /** User-defined fields from Standard Schema */ fields: TFields; /** Additional notes added via .addNote() */ diff --git a/packages/errors/tests/error.test.ts b/packages/errors/tests/error.test.ts index c885ea5..a71ead3 100644 --- a/packages/errors/tests/error.test.ts +++ b/packages/errors/tests/error.test.ts @@ -18,6 +18,80 @@ const createMockSchema = (name = 'mock'): StandardSchemaV1 => { }; describe('error() factory function', () => { + describe('ErrorInstance brand (issue #86)', () => { + it('should attach the brand marker on construction', () => { + // The brand is set by `error()` only. The instance carries + // two symbol-keyed properties: `FACTORY_SYMBOL` (the marker + // used by `is()`) and `ErrorInstanceBrand` (the type-level + // brand introduced in #86). We verify both are present and + // that the brand slot holds the literal 'ErrorInstance'. + const TestError = error({ name: 'TestError' }); + const instance = TestError(); + + const symbols = Object.getOwnPropertySymbols(instance); + expect(symbols.length).toBeGreaterThanOrEqual(2); + + // Find the slot whose value is the brand literal (the + // factory slot holds the TestError function itself). + const slots = symbols.map((sym) => ({ + sym, + value: (instance as unknown as Record)[sym], + })); + const brandSlot = slots.find((s) => s.value === 'ErrorInstance'); + expect(brandSlot).toBeDefined(); + }); + + it('should not be assignable to a plain Error', () => { + // Compile-time guard: a plain Error literal cannot satisfy + // ErrorInstance because the brand is missing. The + // @ts-expect-error marks the line that must fail to compile. + const TestError = error({ name: 'TestError' }); + const instance = TestError(); + + // The structural shape alone still satisfies ErrorInstance + // (TS would accept this *without* the brand — that's the + // smell #86 is closing). With the brand, the literal form + // below is rejected: + const literal = { + name: 'X', + message: 'X', + stack: 'X', + fields: {}, + notes: [], + cause: null, + causes: [], + context: null, + }; + // @ts-expect-error — literal lacks the brand; cannot satisfy ErrorInstance + const _typed: ErrorInstance = literal as ErrorInstance; + void _typed; + + // The runtime counterpart: the factory-produced instance + // has the brand; the literal does not. + const symbols = Object.getOwnPropertySymbols(instance); + expect(symbols.length).toBeGreaterThan(0); + + void instance; + }); + + it('should be assignable from error() output only', () => { + // A native Error cannot satisfy ErrorInstance at the type + // level — the brand is missing. We verify the rejection at + // compile time and confirm the runtime shape does not lie. + const native = new Error('native'); + + // @ts-expect-error — native Error lacks the brand; cannot + // satisfy ErrorInstance. + const _wrong: ErrorInstance = native as unknown as ErrorInstance; + void _wrong; + + // The runtime counterpart: the brand key is absent on the + // native Error, so any narrowing that assumes the brand + // would crash at runtime if it ran unchecked. + const symbols = Object.getOwnPropertySymbols(native); + expect(symbols.length).toBe(0); + }); + }); describe('basic usage', () => { it('should create an error factory with only a name', () => { const NotFoundError = error({ From 0f701576b1d657c309394e39ed72a9edb050f5c0 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Wed, 12 Aug 2026 12:09:12 +0200 Subject: [PATCH 2/2] refactor(errors): encapsulate brand-instance cast in a private helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous shape carried the brand cast inline at the construction site: ```ts (instance as { [ErrorInstanceBrand]: 'ErrorInstance' })[ErrorInstanceBrand] = 'ErrorInstance'; ``` This is exactly the smell rule 0008 targets: a cast that exists only because the property is declared `readonly`, in the one place where the cast is permitted. If someone moves the construction logic elsewhere, they have to copy the cast — the wrapper-class pattern rule 0013 refuses. Extract the cast into `brandInstance(instance)`, a private helper inside `error.ts`. The helper is the single assignment site for the brand; the cast it carries is not exported and cannot be reproduced by consumers. The construction site is now one line: `brandInstance(instance);`. Public API unchanged. 85/85 tests pass. --- packages/errors/src/error/error.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index f154ab9..c462dd7 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -23,6 +23,20 @@ import { formatTemplate, hasTemplatePlaceholders } from './format.js'; */ const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory'); +/** + * Attach the brand marker to a freshly-constructed instance. + * + * This is the only place in the codebase that performs the brand + * assignment. The cast inside is local to the helper and cannot be + * reproduced by consumers — the brand property is declared `readonly` + * on `ErrorInstance`, and the helper is not exported. + * + * @internal + */ +const brandInstance = (instance: ErrorInstance>): void => { + (instance as { [ErrorInstanceBrand]: 'ErrorInstance' })[ErrorInstanceBrand] = 'ErrorInstance'; +}; + // ============================================================================ // Error Factory // ============================================================================ @@ -108,15 +122,11 @@ export const error = = Record distinguishable from a duck-typed - // object at the type level (see ErrorInstanceBrand in types.ts). - // The cast drops the `readonly` modifier locally so the assignment - // compiles; the runtime invariant (only `error()` sets the brand) - // is enforced by the fact that the brand property is declared - // `readonly` on the type, so consumer code cannot assign it - // without an `as` escape hatch. - (instance as { [ErrorInstanceBrand]: 'ErrorInstance' })[ErrorInstanceBrand] = 'ErrorInstance'; + // Brand the instance. The `readonly` modifier on the brand + // property is intentional: it prevents consumer code from minting + // branded instances. The helper below is the single internal + // escape hatch; the cast it carries is not exposed. + brandInstance(instance); instance.stack = stack; // Add .from() method for exception chaining