From 83aceee4339c5aee6d18d7bda26aba357eae06d3 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Fri, 29 May 2026 10:31:22 +0200 Subject: [PATCH 1/2] feat: implement is() type checking function Add is() function for checking error type with inheritance support: - Symbol-based factory identity (prevents name collision issues) - Proper native error handling via instanceof - Inheritance chain walking for parent type checks - Full instanceof compatibility (errors extend native Error) Key fixes after review: - Use Symbol.for() for factory identity instead of string names - Leverage instanceof for native errors instead of hardcoded list - Store factory reference via Symbol on error instances - Errors now properly extend Error class for native compatibility Co-Authored-By: Claude Opus 4.7 --- packages/errors/src/error/error.ts | 71 +++++++++--- packages/errors/src/error/types.ts | 2 - packages/errors/src/index.ts | 3 + packages/errors/src/is/index.ts | 92 ++++++++++++++++ packages/errors/tests/error.test.ts | 11 +- packages/errors/tests/is.test.ts | 164 ++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 21 deletions(-) create mode 100644 packages/errors/src/is/index.ts create mode 100644 packages/errors/tests/is.test.ts diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index 3b94cdf..a9548f8 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -10,6 +10,42 @@ 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' ); + +/** + * Type for the factory marker stored on error instances. + * Uses unknown to avoid generic parameter conflicts. + * + * @internal + */ +type FactoryMarker = { + [FACTORY_SYMBOL]?: unknown; +}; + +/** + * Checks if an object was created by a specific error factory. + * Uses Symbol-based reference comparison to avoid name collisions. + * + * @internal + */ +const hasFactory = ( obj: unknown, factory: ErrorFactory ): obj is ErrorInstance & FactoryMarker => { + if ( obj == null || typeof obj !== 'object' ) { + return false; + } + const marker = obj as FactoryMarker; + return marker[FACTORY_SYMBOL] === factory; +}; + // ============================================================================ // Error Factory // ============================================================================ @@ -85,21 +121,24 @@ export const error = = Record, - }; + // Create error instance using native Error + const instance = new Error( errorMessage ) as ErrorInstance & FactoryMarker; + 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) + instance[FACTORY_SYMBOL] = ErrorFactoryInstance; + + return instance; }; // Attach metadata to the factory function @@ -124,3 +163,9 @@ export const error = = Record; }; + +// ============================================================================ +// Exports for is() function +// ============================================================================ + +export { FACTORY_SYMBOL, hasFactory }; 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..b61b1cc --- /dev/null +++ b/packages/errors/src/is/index.ts @@ -0,0 +1,92 @@ +/** + * Error type checking utilities. + */ + +import type { ErrorFactory, ErrorInstance } from '../error/types.js'; +import { hasFactory } from '../error/error.js'; + +/** + * 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: unknown, + ErrorType: T +): error is ErrorInstance => { + // Handle null/undefined + if ( error == null ) { + return false; + } + + // Handle native errors (TypeScript constructor comparison) + if ( typeof ErrorType === 'function' && 'prototype' in ErrorType ) { + try { + if ( error instanceof ErrorType ) { + return true; + } + } catch { + // instanceof can fail for certain cross-realm errors + } + } + + // Handle our ErrorFactory instances using Symbol-based identity + if ( hasFactory( error, ErrorType ) ) { + return true; + } + + // Check inheritance chain for parent type matching + if ( error instanceof Error ) { + const instance = error as Error & { inherits?: ErrorFactory | ErrorFactory[] }; + const targetFactory = ErrorType; + + // Walk inheritance chain to check if any ancestor matches + let current: ErrorFactory | ErrorFactory[] | undefined = instance.inherits; + + while ( current !== undefined ) { + // Normalize to array for iteration + const factories = Array.isArray( current ) ? current : [current]; + + for ( const factory of factories ) { + if ( factory === targetFactory ) { + return true; + } + // Continue walking up the chain + current = factory.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 From 7b864094c3aef87d4cfc3cfb71905ffeb3e46855 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Fri, 29 May 2026 10:50:34 +0200 Subject: [PATCH 2/2] refactor: improve is() implementation based on review Key fixes: - DFS algorithm with stack for proper multiple inheritance support - Proper type inference: ExtractFields for accurate type narrowing - Cyclic protection with seen Set - No array allocation per iteration (reuse stack) - Instance points to factory, factory holds inheritance metadata Type safety improvements: - is(err, SyntaxError) now returns error is SyntaxError - is(err, CustomError) returns error is ErrorInstance Co-Authored-By: Claude Opus 4.7 --- packages/errors/src/error/error.ts | 31 ++------------ packages/errors/src/is/index.ts | 69 ++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 49 deletions(-) diff --git a/packages/errors/src/error/error.ts b/packages/errors/src/error/error.ts index a9548f8..84429ef 100644 --- a/packages/errors/src/error/error.ts +++ b/packages/errors/src/error/error.ts @@ -22,30 +22,6 @@ import { formatTemplate, hasTemplatePlaceholders } from './format.js'; */ const FACTORY_SYMBOL = Symbol.for( '@deessejs/errors/factory' ); -/** - * Type for the factory marker stored on error instances. - * Uses unknown to avoid generic parameter conflicts. - * - * @internal - */ -type FactoryMarker = { - [FACTORY_SYMBOL]?: unknown; -}; - -/** - * Checks if an object was created by a specific error factory. - * Uses Symbol-based reference comparison to avoid name collisions. - * - * @internal - */ -const hasFactory = ( obj: unknown, factory: ErrorFactory ): obj is ErrorInstance & FactoryMarker => { - if ( obj == null || typeof obj !== 'object' ) { - return false; - } - const marker = obj as FactoryMarker; - return marker[FACTORY_SYMBOL] === factory; -}; - // ============================================================================ // Error Factory // ============================================================================ @@ -125,7 +101,7 @@ export const error = = Record & FactoryMarker; + const instance = new Error( errorMessage ) as ErrorInstance; instance.name = name; instance.fields = fieldsData; instance.notes = []; @@ -136,7 +112,8 @@ export const error = = Record unknown> )[FACTORY_SYMBOL] = ErrorFactoryInstance; return instance; }; @@ -168,4 +145,4 @@ export const error = = Record = 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. @@ -39,49 +52,61 @@ import { hasFactory } from '../error/error.js'; * } * ``` */ -const is = ( +const is = Error )>( error: unknown, ErrorType: T -): error is ErrorInstance => { +): error is ErrorInstance> => { // Handle null/undefined if ( error == null ) { return false; } - // Handle native errors (TypeScript constructor comparison) + // 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 certain cross-realm errors + // instanceof can fail for cross-realm errors } } - // Handle our ErrorFactory instances using Symbol-based identity - if ( hasFactory( error, ErrorType ) ) { - return true; - } + // Handle our ErrorFactory instances using Symbol-based reference + if ( typeof error === 'object' && error !== null ) { + const marker = error as Record; + const factory = marker[FACTORY_SYMBOL]; - // Check inheritance chain for parent type matching - if ( error instanceof Error ) { - const instance = error as Error & { inherits?: ErrorFactory | ErrorFactory[] }; - const targetFactory = ErrorType; + if ( factory !== undefined ) { + // DFS walk of inheritance tree using stack (prevents GC pressure) + const stack: ErrorFactory[] = [factory as ErrorFactory]; + const seen = new Set(); - // Walk inheritance chain to check if any ancestor matches - let current: ErrorFactory | ErrorFactory[] | undefined = instance.inherits; + while ( stack.length > 0 ) { + const current = stack.pop()!; - while ( current !== undefined ) { - // Normalize to array for iteration - const factories = Array.isArray( current ) ? current : [current]; + // Prevent infinite loops in cyclic inheritance + if ( seen.has( current ) ) { + continue; + } + seen.add( current ); - for ( const factory of factories ) { - if ( factory === targetFactory ) { + // Direct match + if ( current === ErrorType ) { return true; } - // Continue walking up the chain - current = factory.inherits; + + // 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 ); + } + } } } }