Skip to content
Merged
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
12 changes: 12 additions & 0 deletions packages/errors/src/error/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ export const error = <const T extends Record<string, unknown> = Record<string, n
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;
};

// 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;
Expand Down
18 changes: 15 additions & 3 deletions packages/errors/src/error/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,28 @@ export type ErrorFactory<TFields extends Record<string, unknown> = Record<string
* Error instance returned by an ErrorFactory.
* Contains all standard Error properties plus additional domain-specific fields.
*
* Note: Methods like .addNote() and .from() are implemented in separate tasks.
* Note: .addNote() is implemented in a separate task.
*/
export type ErrorInstance<TFields extends Record<string, unknown> = Record<string, never>> =
ErrorInstanceCore & {
/** User-defined fields from Standard Schema */
fields: TFields;
// TODO: Implement .addNote() method (Task 05)
// TODO: Implement .addNote() method (Task XX)
/** Additional notes added via .addNote() */
notes: string[];
// TODO: Implement .from() method (Task 06)
/**
* 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<TFields>;
/** Direct cause of this error (from .from()) */
cause: Error | null;
/** Full cause chain from .from() calls */
Expand Down
178 changes: 178 additions & 0 deletions packages/errors/tests/from.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Unit tests for the .from() method.
*/

import { describe, it, expect } from 'vitest';
import { error } from '../src/index.js';

describe( '.from() method', () => {
describe( 'basic usage', () => {
it( 'should set the cause property', () => {
const AppError = error( { name: 'AppError' } );
const ValidationError = error( { name: 'ValidationError' } );

const cause = AppError();
const instance = ValidationError();

const result = instance.from( cause );

expect( instance.cause ).toBe( cause );
} );

it( 'should return the instance for chaining', () => {
const AppError = error( { name: 'AppError' } );
const instance = AppError();

const result = instance.from( new Error( 'cause' ) );

expect( result ).toBe( instance );
} );

it( 'should work with native errors', () => {
const AppError = error( { name: 'AppError' } );
const instance = AppError();

instance.from( new TypeError( 'native cause' ) );

expect( instance.cause ).toBeInstanceOf( TypeError );
expect( instance.cause!.message ).toBe( 'native cause' );
} );
} );

describe( 'cause chain', () => {
it( 'should add cause to causes array', () => {
const AppError = error( { name: 'AppError' } );
const instance = AppError();

instance.from( new Error( 'cause' ) );

expect( instance.causes ).toHaveLength( 1 );
expect( instance.causes[0].message ).toBe( 'cause' );
} );

it( 'should preserve nested cause chain', () => {
const AppError = error( { name: 'AppError' } );
const cause1 = AppError();
const cause2 = AppError();
cause2.from( cause1 );

const instance = AppError();
instance.from( cause2 );

expect( instance.causes ).toHaveLength( 2 );
// Direct cause is cause2, then cause1 (from cause2's chain)
expect( instance.cause ).toBe( cause2 );
expect( instance.causes ).toContain( cause1 );
expect( instance.causes ).toContain( cause2 );
} );

it( 'should build complete cause chain', () => {
const AppError = error( { name: 'AppError' } );
const cause1 = AppError();
const cause2 = AppError();
const cause3 = AppError();
cause3.from( cause2 ).from( cause1 );

const instance = AppError();
instance.from( cause3 );

// causes array contains the full chain: newest first
expect( instance.causes ).toHaveLength( 3 );
expect( instance.cause ).toBe( cause3 );
// Verify all causes are present (order reflects build order)
expect( instance.causes ).toContain( cause3 );
expect( instance.causes ).toContain( cause2 );
expect( instance.causes ).toContain( cause1 );
} );
} );

describe( 'method chaining', () => {
it( 'should support chaining multiple .from() calls', () => {
const AppError = error( { name: 'AppError' } );
const instance = AppError();

const result = instance
.from( new Error( 'cause 1' ) )
.from( new Error( 'cause 2' ) )
.from( new Error( 'cause 3' ) );

expect( result ).toBe( instance );
expect( instance.causes ).toHaveLength( 3 );
} );

it( 'should update cause when chaining', () => {
const AppError = error( { name: 'AppError' } );
const instance = AppError();
const cause1 = new Error( 'cause 1' );
const cause2 = new Error( 'cause 2' );

instance.from( cause1 ).from( cause2 );

// The direct cause should be the last one
expect( instance.cause ).toBe( cause2 );
// But causes array should have both
expect( instance.causes ).toContain( cause1 );
expect( instance.causes ).toContain( cause2 );
} );
} );

describe( 'type safety', () => {
it( 'should work with typed errors', () => {
const AppError = error<{ code: string }>( { name: 'AppError' } );
const ValidationError = error<{ field: string }>( { name: 'ValidationError' } );

const cause = AppError( { code: 'ERR001' } );
const instance = ValidationError( { field: 'email' } );

instance.from( cause );

expect( instance.cause ).toBe( cause );
expect( ( instance.cause as AppError ).fields.code ).toBe( 'ERR001' );
} );

it( 'should maintain instance fields after .from()', () => {
const ValidationError = error<{ field: string }>( {
name: 'ValidationError',
message: 'Field "{field}" is invalid',
} );

const instance = ValidationError( { field: 'email' } );
instance.from( new Error( 'network error' ) );

expect( instance.fields.field ).toBe( 'email' );
expect( instance.message ).toBe( 'Field "email" is invalid' );
} );
} );

describe( 'edge cases', () => {
it( 'should work with errors without causes property', () => {
const AppError = error( { name: 'AppError' } );
const instance = AppError();

// Native errors don't have causes property
instance.from( new Error( 'native' ) );

expect( instance.cause ).toBeInstanceOf( Error );
expect( instance.causes ).toHaveLength( 1 );
} );

it( 'should work with custom errors that have causes', () => {
const AppError = error( { name: 'AppError' } );
const ValidationError = error( { name: 'ValidationError' } );

const innerError = AppError();
innerError.from( new Error( 'inner cause' ) );

const outerError = ValidationError();
outerError.from( innerError );

expect( outerError.causes ).toHaveLength( 2 );
// Direct cause is innerError, then nativeError (from innerError's chain)
expect( outerError.cause ).toBe( innerError );
expect( outerError.causes ).toContain( innerError );
// The native error with 'inner cause' is in the causes chain
const nativeInCauses = outerError.causes.find( c => c.message === 'inner cause' );
expect( nativeInCauses ).toBeDefined();
} );
} );
} );