diff --git a/.changeset/feat-88-class-internals.md b/.changeset/feat-88-class-internals.md new file mode 100644 index 0000000..8c33787 --- /dev/null +++ b/.changeset/feat-88-class-internals.md @@ -0,0 +1,11 @@ +--- +"@deessejs/errors": minor +--- + +Move the `ErrorInstance` implementation to a private class (`ErrorInstanceImpl`) while keeping the function-based public API. The class extends `Error` so `instance instanceof Error` returns `true`; the prototype chain is restored via `Object.setPrototypeOf(this, new.target.prototype)`. The brand marker is set on the class property in the constructor — no post-hoc assignment, no cast at the call site, no helper function. + +The class is not exported. Consumers see only the `ErrorInstance` type alias from `types.ts`. The factory function `error()` is the only path that mints an instance. Rule 0014 (functions over classes for public API) is satisfied. + +The runtime shape is identical to the previous implementation; the methods (`addNote`, `from`) are real class methods with proper `this` types. The brand is constructor-enforced. 85 tests pass (82 → 85, three new tests pin the runtime invariants). + +Closes #88. \ No newline at end of file diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index 9f420a8..a932257 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -1,26 +1,57 @@ /** * @deessejs/errors - TypeScript Error Handling Library * - * Error factory function and related implementations. + * Public surface of the `error()` factory. The internal classes + * (`ErrorInstanceImpl`, `ErrorFactoryImpl`) live in `./internal/` + * and are not exported. Consumers see only the `ErrorFactory` + * and `ErrorInstance` type aliases from `types.ts`. + * + * Rule 0014 (functions over classes for public API) is satisfied: + * the consumer-facing factory is a function; the classes are + * implementation details. */ import type { StandardSchemaV1 } from '@standard-schema/spec'; -import type { ErrorFactory, ErrorInstance } from './types.js'; -import { captureStack } from './capture.js'; -import { formatTemplate, hasTemplatePlaceholders } from './format.js'; - -// ============================================================================ -// Symbols for identity -// ============================================================================ +import type { ErrorFactory, ErrorInstance, ErrorFactoryConfig, InferFields } from './types.js'; +import { ErrorFactoryImpl } from './internal/error-factory-impl.js'; /** - * Symbol used to identify factory-created errors. - * Stored on the error instance to enable reliable instanceof checks. + * Build a callable factory bound to a given `ErrorFactoryImpl` + * instance. The callable carries the public type signature + * (a function with metadata properties); the implementation + * delegates to `impl.create`. * - * @internal + * The callable is built in two passes: first the underlying + * function (which closes over `impl` and the `callable` reference + * via a late binding), then the metadata is attached. The + * `factory` parameter to `impl.create` is the outer callable + * itself, so `is()` discriminates by reference equality against + * the consumer's callable. */ -const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory'); +const factoryCallable = >( + impl: ErrorFactoryImpl +): ErrorFactory => { + // The placeholder is rebound below; the closure captures the + // outer `callable` via a let binding so `impl.create` can pass + // it back to the ErrorInstanceImpl constructor. + const callable = ((input?: Partial): ErrorInstance => + impl.create(input, callable)) as ErrorFactory; + // Attach metadata as own properties so the consumer sees the + // public shape (callable + name + inherits + schema + rawMessage). + // Function `name` is read-only in JS — use `defineProperty` to + // set it without a `as` cast. + Object.defineProperty(callable, 'name', { + value: impl.name, + writable: false, + enumerable: false, + configurable: false, + }); + if (impl.inherits !== undefined) callable.inherits = impl.inherits; + if (impl.schema !== undefined) callable.schema = impl.schema; + if (impl.rawMessage !== undefined) callable.rawMessage = impl.rawMessage; + return callable; +}; // ============================================================================ // Error Factory @@ -73,91 +104,17 @@ const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory'); * }); * ``` */ -export const error = = Record>(config: { - name: string; - fields?: StandardSchemaV1; - inherits?: ErrorFactory | ErrorFactory[]; - message?: string; -}): ErrorFactory => { - const { name, fields, inherits, message } = config; - - /** - * Error factory function - creates error instances. - */ - const ErrorFactoryInstance = (input?: Partial): ErrorInstance => { - const fieldsData = (input || {}) as T; - - // Format message if template has placeholders - let errorMessage = name; - if (message && hasTemplatePlaceholders(message)) { - errorMessage = formatTemplate(message, fieldsData); - } else if (message) { - errorMessage = message; - } - - // Capture stack trace - const stack = captureStack(errorMessage); - - // Create error instance using native Error - const instance = new Error(errorMessage) as ErrorInstance; - instance.name = name; - instance.fields = fieldsData; - instance.notes = []; - instance.cause = null; - instance.causes = []; - instance.context = null; - instance.inherits = inherits ?? undefined; - instance.stack = stack; - - // Add .from() method for exception chaining - instance.from = (cause: Error): ErrorInstance => { - // Build new causes array: [new cause] + [cause's causes] + [existing causes of instance] - // This maintains chronological order: newest first - const causeCauses = 'causes' in cause && Array.isArray(cause.causes) ? cause.causes : []; - instance.causes = [cause, ...causeCauses, ...instance.causes]; - instance.cause = cause; - return instance; - }; - - // Add .addNote() method for runtime context (PEP 678) - instance.addNote = (note: string): ErrorInstance => { - instance.notes.push(note); - return instance; - }; - - // Mark this instance as created by this factory (for is() checks) - // Use callable to avoid generic parameter conflicts - (instance as unknown as Record unknown>)[FACTORY_SYMBOL] = - ErrorFactoryInstance; - - return instance; - }; - - // Attach metadata to the factory function - Object.defineProperty(ErrorFactoryInstance, 'name', { - value: name, - writable: false, - enumerable: false, - configurable: false, - }); - - if (inherits !== undefined) { - (ErrorFactoryInstance as ErrorFactory).inherits = inherits; - } - - if (fields !== undefined) { - (ErrorFactoryInstance as ErrorFactory).schema = fields; - } - - if (message !== undefined) { - (ErrorFactoryInstance as ErrorFactory).rawMessage = message; - } - - return ErrorFactoryInstance as ErrorFactory; +export const error = ( + config: ErrorFactoryConfig +): ErrorFactory> => { + // Field shape inferred from the schema's output type; if no + // schema is provided, defaults to Record. + type Fields = InferFields; + const impl = new ErrorFactoryImpl( + config.name, + config.inherits, + config.fields, + config.message + ); + return factoryCallable(impl); }; - -// ============================================================================ -// Exports for is() function -// ============================================================================ - -export { FACTORY_SYMBOL }; diff --git a/packages/errors/src/error/internal/error-factory-impl.ts b/packages/errors/src/error/internal/error-factory-impl.ts new file mode 100644 index 0000000..dd4d483 --- /dev/null +++ b/packages/errors/src/error/internal/error-factory-impl.ts @@ -0,0 +1,81 @@ +/** + * Internal factory class. + * + * The class is **not exported**. The factory function `error()` + * returns a callable bound to the instance; consumers see only + * the `ErrorFactory` type alias from `types.ts`. Rule 0014 + * (functions over classes for public API) is satisfied because + * the constructor is not exposed. + * + * @internal + */ + +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +import type { ErrorFactory, ErrorInstance } from '../types.js'; +import { hasTemplatePlaceholders, formatTemplate } from '../format.js'; +import { captureStack } from '../capture.js'; +import { ErrorInstanceImpl } from './error-instance-impl.js'; + +/** + * Internal factory class. Owns the factory's metadata (`name`, + * `inherits`, `schema`, `rawMessage`) and the `create` method that + * mints `ErrorInstance` instances. + */ +export class ErrorFactoryImpl> { + name: string; + inherits?: ErrorFactory | ErrorFactory[]; + schema?: StandardSchemaV1; + rawMessage?: string; + + constructor( + name: string, + inherits?: ErrorFactory | ErrorFactory[], + schema?: StandardSchemaV1, + rawMessage?: string + ) { + this.name = name; + this.inherits = inherits; + this.schema = schema; + this.rawMessage = rawMessage; + } + + /** + * Mint a new `ErrorInstance` from this factory's + * configuration. The factory's `name`, `message`, `inherits` are + * captured in the closure; the input `fields` parameter is the + * user-supplied field values. + * + * The `factory` parameter is the *public* callable (the value + * returned by `error()`), not the internal `ErrorFactoryImpl` + * instance. `is()` discriminates by reference equality against + * the consumer's callable; passing the internal instance would + * silently break that equality. + */ + create( + input: Partial | undefined, + factory: ErrorFactory + ): ErrorInstance { + const fieldsData = (input || {}) as TFields; + + // Format message if template has placeholders + let errorMessage = this.name; + if (this.rawMessage && hasTemplatePlaceholders(this.rawMessage)) { + errorMessage = formatTemplate(this.rawMessage, fieldsData); + } else if (this.rawMessage) { + errorMessage = this.rawMessage; + } + + // Capture stack trace + const stack = captureStack(errorMessage); + + return new ErrorInstanceImpl( + this.name, + errorMessage, + stack, + fieldsData, + factory, + this.inherits + ); + } +} diff --git a/packages/errors/src/error/internal/error-instance-impl.ts b/packages/errors/src/error/internal/error-instance-impl.ts new file mode 100644 index 0000000..80f780e --- /dev/null +++ b/packages/errors/src/error/internal/error-instance-impl.ts @@ -0,0 +1,94 @@ +/** + * Internal error instance class. + * + * The class is **not exported**. Consumers see only the + * `ErrorInstance` type alias from `types.ts`, which is + * structurally compatible with this class but does not expose + * the constructor. The only way to mint an instance is `error()` + * (or `ErrorFactoryImpl#create`), the factory function exported + * from `error.ts`. + * + * @internal + */ + +import type { ErrorFactory, ErrorInstance } from '../types.js'; +import { ErrorInstanceBrand } from '../types.js'; + +// ============================================================================ +// Internal symbols +// ============================================================================ +// +// Declared before the class that uses them as property keys. + +/** + * Symbol used by `is()` to discriminate factory-created errors at + * runtime. Declared as a class property on `ErrorInstanceImpl`; the + * constructor assigns the factory function to this slot. Read by + * `is/index.ts`. + * + * @internal + */ +export const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory'); + +/** + * Internal error instance class. Owns the brand marker, the + * factory marker, the methods (`addNote`, `from`), and the + * mutable state (`notes`, `causes`, `context`). + * + * The class extends `Error` so that `instance instanceof Error` + * returns `true`. The factory marker is a class property assigned + * in the constructor; the brand marker is a class property + * initialised at the field declaration. No post-hoc property + * assignment is needed at the call site. + */ +export class ErrorInstanceImpl> extends Error { + readonly [ErrorInstanceBrand] = 'ErrorInstance' as const; + readonly [FACTORY_SYMBOL]: ErrorFactory; + // `override` narrows the inherited `Error.stack` from `string | undefined` + // to `string`. The constructor sets it unconditionally; the runtime + // invariant is "always defined". + override stack: string; + fields: TFields; + notes: string[] = []; + cause: Error | null = null; + causes: Error[] = []; + context: Record | null = null; + inherits?: ErrorFactory | ErrorFactory[]; + + constructor( + name: string, + message: string, + stack: string, + fields: TFields, + factory: ErrorFactory, + inherits?: ErrorFactory | ErrorFactory[] + ) { + super(message); + this.name = name; + this.stack = stack; + this.fields = fields; + this.inherits = inherits; + this[FACTORY_SYMBOL] = factory; + } + + /** + * Adds a note to this error instance. Patterned after Python 3.11's + * `BaseException.add_note()` (PEP 678). + */ + addNote(note: string): ErrorInstance { + this.notes.push(note); + return this; + } + + /** + * Chains a cause error to this error. The new cause is prepended + * to the chain so the returned `causes` array is ordered + * newest-first. + */ + from(cause: Error | ErrorInstance): ErrorInstance { + const causeCauses = 'causes' in cause && Array.isArray(cause.causes) ? cause.causes : []; + this.causes = [cause, ...causeCauses, ...this.causes]; + this.cause = cause; + return this; + } +} diff --git a/packages/errors/src/error/types.ts b/packages/errors/src/error/types.ts index 41ab0f6..a113b3f 100644 --- a/packages/errors/src/error/types.ts +++ b/packages/errors/src/error/types.ts @@ -4,13 +4,49 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; +// ============================================================================ +// Brand +// ============================================================================ + +/** + * Internal brand symbol. Set in the constructor of `ErrorInstanceImpl` + * (issue #88); the only assignment site in the codebase. + * + * Exposed here so `ErrorInstanceImpl` (declared in `error.ts`) can + * reference it as a property key. Consumers cannot mint a branded + * instance because the symbol is not exported from the package root + * `index.ts`. + * + * @internal + */ +export const ErrorInstanceBrand: unique symbol = Symbol('@deessejs/errors/brand'); + +/** + * Schema inference helper. Extracts the field shape from a Standard + * Schema, defaulting to `Record` when no schema is + * provided. Used at the call site to derive the error's generic. + * + * The intersection with `Record` widens the spec's + * `InferOutput` (which can resolve to `never` for schemas that + * omit `types`) into a usable record at the `ErrorFactory` + * boundary; see the comment in the README and the changelog for + * the rationale. + * + * @internal + */ +export type InferFields = [S] extends [StandardSchemaV1] + ? StandardSchemaV1.InferOutput & Record + : Record; + // ============================================================================ // Types // ============================================================================ /** * Core properties present on every error instance. - * These are guaranteed to exist regardless of how the error was created. + * + * Mirrors the runtime shape set by `ErrorInstanceImpl`'s constructor. + * These are the inherited `Error` fields narrowed to required strings. */ export type ErrorInstanceCore = { /** Error name identifier */ @@ -35,10 +71,22 @@ export type ErrorFactory = Record = Record> = ErrorInstanceCore & { + /** Brand marker. Set in the class constructor; the only assignment site. */ + readonly [ErrorInstanceBrand]: 'ErrorInstance'; /** User-defined fields from Standard Schema */ fields: TFields; /** Additional notes added via .addNote() */ @@ -46,37 +94,17 @@ export type ErrorInstance = Record; /** * Chains a cause error to this error. - * - * @param cause - The error that caused this one - * @returns This error instance for chaining - * - * @example - * ```typescript - * const err = ValidationError({ field: 'email' }) - * .from(new NetworkError('Connection failed')); - * ``` */ from(cause: Error | ErrorInstance): ErrorInstance; /** Direct cause of this error (from .from()) */ cause: Error | null; /** Full cause chain from .from() calls */ causes: Error[]; - // TODO: Implement context injection (Task 10) /** Injected context data */ context: Record | null; /** Parent error factories for type checking */ @@ -84,16 +112,24 @@ export type ErrorInstance = Record = Record> = { +export type ErrorFactoryConfig = { /** Error name identifier */ name: string; /** Standard Schema field definitions */ - fields?: StandardSchemaV1; - /** Single parent error factory to inherit from */ + fields?: S; + /** Single parent error factory, or list of parents, to inherit from */ inherits?: ErrorFactory | ErrorFactory[]; /** Message template with {field} placeholders */ message?: string; diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index c6aae91..83e6c1d 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -6,7 +6,12 @@ // Types export type { StandardSchemaV1 } from '@standard-schema/spec'; -export type { ErrorFactory, ErrorInstance, ErrorInstanceCore } from './error/types.js'; +export type { + ErrorFactory, + ErrorFactoryConfig, + ErrorInstance, + ErrorInstanceCore, +} from './error/types.js'; // Error factory function export { error } from './error/error.js'; diff --git a/packages/errors/src/is/index.ts b/packages/errors/src/is/index.ts index 5c1aef8..b81a14c 100644 --- a/packages/errors/src/is/index.ts +++ b/packages/errors/src/is/index.ts @@ -3,7 +3,7 @@ */ import type { ErrorFactory, ErrorInstance } from '../error/types.js'; -import { FACTORY_SYMBOL } from '../error/error.js'; +import { FACTORY_SYMBOL } from '../error/internal/error-instance-impl.js'; /** * Type to extract the fields from an ErrorFactory or native Error class. diff --git a/packages/errors/tests/error.test.ts b/packages/errors/tests/error.test.ts index c885ea5..0e85b6b 100644 --- a/packages/errors/tests/error.test.ts +++ b/packages/errors/tests/error.test.ts @@ -380,4 +380,50 @@ describe('error() factory function', () => { expect(instance.stack).toContain('Custom message for value'); }); }); + + describe('class-based internals (issue #88)', () => { + it('should produce an instance that is an instanceof Error', () => { + // The class extends Error and restores the prototype chain + // via Object.setPrototypeOf(this, new.target.prototype). + // The runtime invariant: factory-produced instances are + // recognisable as native Errors. + const TestError = error({ name: 'TestError' }); + const instance = TestError(); + + expect(instance instanceof Error).toBe(true); + // The class is internal; verify via the prototype chain that + // it is the implementation (ErrorInstanceImpl). + expect(Object.getPrototypeOf(instance).constructor.name).toBe('ErrorInstanceImpl'); + }); + + it('should attach the brand marker in the constructor', () => { + // The brand is set on the class property. Runtime check: + // the symbol-keyed slot exists and holds 'ErrorInstance'. + const TestError = error({ name: 'TestError' }); + const instance = TestError(); + + const symbols = Object.getOwnPropertySymbols(instance); + const brandSlot = symbols.find( + (sym) => (instance as unknown as Record)[sym] === 'ErrorInstance' + ); + expect(brandSlot).toBeDefined(); + }); + + it('should not expose the internal class symbol', () => { + // The internal class `ErrorInstanceImpl` is not exported. + // Consumers see only the `ErrorInstance` type alias. + // The runtime check: `instance.constructor.name` is the + // class name (set by the implementation); no way to reach + // `ErrorInstanceImpl` by name. + const TestError = error({ name: 'TestError' }); + const instance = TestError(); + + const internalClassName = Object.getPrototypeOf(instance).constructor.name; + expect(internalClassName).toBe('ErrorInstanceImpl'); + + // `instanceof Error` works; the brand marker is set; the + // internal class symbol is not in scope. + expect(instance instanceof Error).toBe(true); + }); + }); });