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-88-class-internals.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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.
155 changes: 56 additions & 99 deletions packages/errors/src/error/error.ts
Original file line number Diff line number Diff line change
@@ -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<T>`
* and `ErrorInstance<T>` 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 = <TFields extends Record<string, unknown>>(
impl: ErrorFactoryImpl<TFields>
): ErrorFactory<TFields> => {
// 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<TFields>): ErrorInstance<TFields> =>
impl.create(input, callable)) as ErrorFactory<TFields>;
// 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
Expand Down Expand Up @@ -73,91 +104,17 @@ const FACTORY_SYMBOL = Symbol.for('@deessejs/errors/factory');
* });
* ```
*/
export const error = <const T extends Record<string, unknown> = Record<string, never>>(config: {
name: string;
fields?: StandardSchemaV1;
inherits?: ErrorFactory | ErrorFactory[];
message?: string;
}): ErrorFactory<T> => {
const { name, fields, inherits, message } = config;

/**
* Error factory function - creates error instances.
*/
const ErrorFactoryInstance = (input?: Partial<T>): ErrorInstance<T> => {
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<T>;
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<T> => {
// 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<T> => {
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<typeof FACTORY_SYMBOL, () => 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<T>).inherits = inherits;
}

if (fields !== undefined) {
(ErrorFactoryInstance as ErrorFactory<T>).schema = fields;
}

if (message !== undefined) {
(ErrorFactoryInstance as ErrorFactory<T>).rawMessage = message;
}

return ErrorFactoryInstance as ErrorFactory<T>;
export const error = <const S extends StandardSchemaV1 | undefined = undefined>(
config: ErrorFactoryConfig<S>
): ErrorFactory<InferFields<S>> => {
// Field shape inferred from the schema's output type; if no
// schema is provided, defaults to Record<string, never>.
type Fields = InferFields<S>;
const impl = new ErrorFactoryImpl<Fields>(
config.name,
config.inherits,
config.fields,
config.message
);
return factoryCallable<Fields>(impl);
};

// ============================================================================
// Exports for is() function
// ============================================================================

export { FACTORY_SYMBOL };
81 changes: 81 additions & 0 deletions packages/errors/src/error/internal/error-factory-impl.ts
Original file line number Diff line number Diff line change
@@ -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<T>` 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<TFields extends Record<string, unknown>> {
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<TFields>` 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<TFields> | undefined,
factory: ErrorFactory<TFields>
): ErrorInstance<TFields> {
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<TFields>(
this.name,
errorMessage,
stack,
fieldsData,
factory,
this.inherits
);
}
}
94 changes: 94 additions & 0 deletions packages/errors/src/error/internal/error-instance-impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Internal error instance class.
*
* The class is **not exported**. Consumers see only the
* `ErrorInstance<T>` 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<TFields extends Record<string, unknown>> extends Error {
readonly [ErrorInstanceBrand] = 'ErrorInstance' as const;
readonly [FACTORY_SYMBOL]: ErrorFactory<TFields>;
// `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<string, unknown> | null = null;
inherits?: ErrorFactory | ErrorFactory[];

constructor(
name: string,
message: string,
stack: string,
fields: TFields,
factory: ErrorFactory<TFields>,
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<TFields> {
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<TFields> {
const causeCauses = 'causes' in cause && Array.isArray(cause.causes) ? cause.causes : [];
this.causes = [cause, ...causeCauses, ...this.causes];
this.cause = cause;
return this;
}
}
Loading
Loading