diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index 3b94cdf..84429ef 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -10,6 +10,18 @@ import type { ErrorFactory, ErrorInstance } from './types.js'; import { captureStack } from './capture.js'; import { formatTemplate, hasTemplatePlaceholders } from './format.js'; +// ============================================================================ +// Symbols for identity +// ============================================================================ + +/** + * Symbol used to identify factory-created errors. + * Stored on the error instance to enable reliable instanceof checks. + * + * @internal + */ +const FACTORY_SYMBOL = Symbol.for( '@deessejs/errors/factory' ); + // ============================================================================ // Error Factory // ============================================================================ @@ -85,21 +97,25 @@ export const error = = Record, - }; + // 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; + + // 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 @@ -124,3 +140,9 @@ export const error = = Record; }; + +// ============================================================================ +// Exports for is() function +// ============================================================================ + +export { FACTORY_SYMBOL }; diff --git a/packages/errors/src/error/types.ts b/packages/errors/src/error/types.ts index 5f8f8cb..086e90e 100644 --- a/packages/errors/src/error/types.ts +++ b/packages/errors/src/error/types.ts @@ -56,8 +56,6 @@ export type ErrorInstance = Record | null; /** Parent error factories for type checking */ inherits?: ErrorFactory | ErrorFactory[]; - /** Reference to the factory that created this instance */ - _factory: ErrorFactory; }; /** diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index f272999..caa8939 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -17,3 +17,6 @@ export { error } from './error/error.js'; // Error raising function export { raise } from './raise/index.js'; + +// Error type checking function +export { is } from './is/index.js'; diff --git a/packages/errors/src/is/index.ts b/packages/errors/src/is/index.ts new file mode 100644 index 0000000..c63e9c9 --- /dev/null +++ b/packages/errors/src/is/index.ts @@ -0,0 +1,117 @@ +/** + * Error type checking utilities. + */ + +import type { ErrorFactory, ErrorInstance } from '../error/types.js'; +import { FACTORY_SYMBOL } from '../error/error.js'; + +/** + * Type to extract the fields from an ErrorFactory or native Error class. + * + * @internal + */ +type ExtractFields = T extends ErrorFactory + ? F + : T extends new ( ...args: unknown[] ) => infer E + ? E extends ErrorInstance + ? F + : Record + : Record; + +/** + * Checks if an error is an instance of a specific error type. + * + * Works with: + * - Custom error factories created by error() + * - Single and multiple inheritance hierarchies + * - Native JavaScript errors (TypeError, SyntaxError, etc.) + * + * @param error - The error to check (can be any value) + * @param ErrorType - The error type to check against + * @returns boolean - true if the error is the specified type or inherits from it + * + * @example + * ```typescript + * const AppError = error({ name: 'AppError' }); + * const ValidationError = error({ name: 'ValidationError', inherits: AppError }); + * + * const err = ValidationError(); + * is(err, ValidationError); // true + * is(err, AppError); // true (through inheritance) + * ``` + * + * @example + * ```typescript + * // Works with native errors + * try { + * JSON.parse('invalid'); + * } catch (err) { + * if (is(err, SyntaxError)) { + * // Handle syntax errors + * } + * } + * ``` + */ +const is = Error )>( + error: unknown, + ErrorType: T +): error is ErrorInstance> => { + // Handle null/undefined + if ( error == null ) { + return false; + } + + // Handle native errors - check prototype chain ends in Error + if ( typeof ErrorType === 'function' && 'prototype' in ErrorType ) { + try { + if ( error instanceof ErrorType ) { + return true; + } + } catch { + // instanceof can fail for cross-realm errors + } + } + + // Handle our ErrorFactory instances using Symbol-based reference + if ( typeof error === 'object' && error !== null ) { + const marker = error as Record; + const factory = marker[FACTORY_SYMBOL]; + + if ( factory !== undefined ) { + // DFS walk of inheritance tree using stack (prevents GC pressure) + const stack: ErrorFactory[] = [factory as ErrorFactory]; + const seen = new Set(); + + while ( stack.length > 0 ) { + const current = stack.pop()!; + + // Prevent infinite loops in cyclic inheritance + if ( seen.has( current ) ) { + continue; + } + seen.add( current ); + + // Direct match + if ( current === ErrorType ) { + return true; + } + + // Add parents to stack + const inherits = ( current as ErrorFactory ).inherits; + if ( inherits !== undefined ) { + if ( Array.isArray( inherits ) ) { + for ( let i = 0; i < inherits.length; i++ ) { + stack.push( inherits[i] ); + } + } else { + stack.push( inherits ); + } + } + } + } + } + + return false; +}; + +export { is }; \ No newline at end of file diff --git a/packages/errors/tests/error.test.ts b/packages/errors/tests/error.test.ts index 100e177..7937bf4 100644 --- a/packages/errors/tests/error.test.ts +++ b/packages/errors/tests/error.test.ts @@ -67,11 +67,11 @@ describe( 'error() factory function', () => { expect( instance.context ).toBeNull(); } ); - it( 'should have _factory reference back to the creator', () => { + it( 'should be an instance of Error', () => { const TestError = error( { name: 'TestError' } ); const instance = TestError(); - expect( instance._factory ).toBe( TestError ); + expect( instance instanceof Error ).toBe( true ); } ); it( 'should have inherits reference when inheriting', () => { @@ -304,14 +304,13 @@ describe( 'error() factory function', () => { expect( ErrorA.name ).not.toBe( ErrorB.name ); } ); - it( 'should maintain factory reference on instances', () => { + it( 'should create errors that are instances of Error', () => { const TestError = error( { name: 'TestError' } ); const instance1 = TestError(); const instance2 = TestError(); - expect( instance1._factory ).toBe( TestError ); - expect( instance2._factory ).toBe( TestError ); - expect( instance1._factory ).toBe( instance2._factory ); + expect( instance1 instanceof Error ).toBe( true ); + expect( instance2 instanceof Error ).toBe( true ); } ); } ); diff --git a/packages/errors/tests/is.test.ts b/packages/errors/tests/is.test.ts new file mode 100644 index 0000000..5070ad4 --- /dev/null +++ b/packages/errors/tests/is.test.ts @@ -0,0 +1,164 @@ +/** + * Unit tests for the is() function. + */ + +import { describe, it, expect } from 'vitest'; +import { error, is } from '../src/index.js'; + +describe( 'is() function', () => { + describe( 'basic usage', () => { + it( 'should return true for exact match', () => { + const TestError = error( { name: 'TestError' } ); + const err = TestError(); + + expect( is( err, TestError ) ).toBe( true ); + } ); + + it( 'should return false for non-matching types', () => { + const ErrorA = error( { name: 'ErrorA' } ); + const ErrorB = error( { name: 'ErrorB' } ); + const err = ErrorA(); + + expect( is( err, ErrorB ) ).toBe( false ); + } ); + + it( 'should return false for null/undefined', () => { + const TestError = error( { name: 'TestError' } ); + + expect( is( null, TestError ) ).toBe( false ); + expect( is( undefined, TestError ) ).toBe( false ); + } ); + } ); + + describe( 'single inheritance', () => { + it( 'should return true for child error types', () => { + const AppError = error( { name: 'AppError' } ); + const ValidationError = error( { + name: 'ValidationError', + inherits: AppError, + } ); + const err = ValidationError(); + + expect( is( err, ValidationError ) ).toBe( true ); + expect( is( err, AppError ) ).toBe( true ); + } ); + + it( 'should return false for parent when checking child', () => { + const AppError = error( { name: 'AppError' } ); + const ValidationError = error( { + name: 'ValidationError', + inherits: AppError, + } ); + const err = AppError(); + + expect( is( err, ValidationError ) ).toBe( false ); + expect( is( err, AppError ) ).toBe( true ); + } ); + } ); + + describe( 'multiple inheritance', () => { + it( 'should return true for errors inheriting from multiple parents', () => { + const NetworkError = error( { name: 'NetworkError' } ); + const StorageError = error( { name: 'StorageError' } ); + const CombinedError = error( { + name: 'CombinedError', + inherits: [NetworkError, StorageError], + } ); + const err = CombinedError(); + + expect( is( err, CombinedError ) ).toBe( true ); + expect( is( err, NetworkError ) ).toBe( true ); + expect( is( err, StorageError ) ).toBe( true ); + } ); + + it( 'should return true in deep inheritance chains', () => { + const AppError = error( { name: 'AppError' } ); + const DomainError = error( { + name: 'DomainError', + inherits: AppError, + } ); + const ValidationError = error( { + name: 'ValidationError', + inherits: DomainError, + } ); + const err = ValidationError(); + + expect( is( err, ValidationError ) ).toBe( true ); + expect( is( err, DomainError ) ).toBe( true ); + expect( is( err, AppError ) ).toBe( true ); + } ); + } ); + + describe( 'native errors', () => { + it( 'should work with SyntaxError', () => { + try { + JSON.parse( 'invalid' ); + } catch ( err ) { + expect( is( err, SyntaxError ) ).toBe( true ); + expect( is( err, Error ) ).toBe( true ); + } + } ); + + it( 'should work with TypeError', () => { + try { + const fn: unknown = null; + ( fn as { method: unknown } ).method(); + } catch ( err ) { + expect( is( err, TypeError ) ).toBe( true ); + } + } ); + + it( 'should return false for native when checking custom', () => { + const CustomError = error( { name: 'CustomError' } ); + + try { + JSON.parse( 'invalid' ); + } catch ( err ) { + expect( is( err, CustomError ) ).toBe( false ); + } + } ); + } ); + + describe( 'edge cases', () => { + it( 'should handle non-error values', () => { + const TestError = error( { name: 'TestError' } ); + + expect( is( 'string', TestError ) ).toBe( false ); + expect( is( 123, TestError ) ).toBe( false ); + expect( is( {}, TestError ) ).toBe( false ); + expect( is( [], TestError ) ).toBe( false ); + } ); + + it( 'should handle native errors without factory marker', () => { + const TestError = error( { name: 'TestError' } ); + const nativeErr = new Error( 'test' ); + + expect( is( nativeErr, TestError ) ).toBe( false ); + } ); + + it( 'should return false for native errors when checking factory', () => { + const TestError = error( { name: 'TestError' } ); + const nativeErr = new Error( 'test' ); + + expect( is( nativeErr, TestError ) ).toBe( false ); + } ); + } ); + + describe( 'instanceof compatibility', () => { + it( 'should return true for instanceof Error checks', () => { + const TestError = error( { name: 'TestError' } ); + const err = TestError(); + + expect( err instanceof Error ).toBe( true ); + } ); + + it( 'should work alongside native instanceof', () => { + const TestError = error( { name: 'TestError' } ); + const err = TestError(); + + // Both should work + expect( err instanceof Error ).toBe( true ); + expect( is( err, TestError ) ).toBe( true ); + } ); + } ); +} ); \ No newline at end of file