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..c462dd7 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'; @@ -22,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 // ============================================================================ @@ -107,6 +122,11 @@ export const error = = Record` 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({