Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/feat-86-brand-error-instance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@deessejs/errors": minor
---

Brand `ErrorInstance<TFields>` 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<T>` 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).
20 changes: 20 additions & 0 deletions packages/errors/src/error/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<TFields>`, and the helper is not exported.
*
* @internal
*/
const brandInstance = (instance: ErrorInstance<Record<string, unknown>>): void => {
(instance as { [ErrorInstanceBrand]: 'ErrorInstance' })[ErrorInstanceBrand] = 'ErrorInstance';
};

// ============================================================================
// Error Factory
// ============================================================================
Expand Down Expand Up @@ -107,6 +122,11 @@ export const error = <const T extends Record<string, unknown> = Record<string, n
instance.causes = [];
instance.context = null;
instance.inherits = inherits ?? undefined;
// 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
Expand Down
28 changes: 28 additions & 0 deletions packages/errors/src/error/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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
// ============================================================================
Expand Down Expand Up @@ -39,6 +58,15 @@ export type ErrorFactory<TFields extends Record<string, unknown> = Record<string
*/
export type ErrorInstance<TFields extends Record<string, unknown> = Record<string, never>> =
ErrorInstanceCore & {
/**
* Brand marker. Set by `error()` at construction time; cannot be
* set by any other code path. The brand is what makes
* `ErrorInstance<T>` 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() */
Expand Down
74 changes: 74 additions & 0 deletions packages/errors/tests/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,80 @@ const createMockSchema = <T>(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<symbol, unknown>)[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<T> 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<T>
// (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<T>
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<T> 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<T>.
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({
Expand Down
Loading