diff --git a/src/utilities.ts b/src/utilities.ts index 597d8fd..a4dc24d 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -5,6 +5,8 @@ * @param defaultMessage The default message if no message is available. * * @returns The message or `null` if no message is available. + * + * @deprecated use {@link extractErrorDetails} */ export function extractErrorMessage(error: unknown, defaultMessage = 'Unknown error'): string { if (error instanceof Error && error.message !== '') { @@ -17,3 +19,72 @@ export function extractErrorMessage(error: unknown, defaultMessage = 'Unknown er return defaultMessage; } + +/** + * Extracted details about an error value. + */ +export type ErrorDetails = { + /** The error message. */ + message: string, + + /** The stack trace of the error, if available. */ + stack?: string, + + /** The details of the error that caused this error, if part of a chain. */ + cause?: ErrorDetails, +}; + +/** + * Extracts the details from an error of unknown type. + * + * If the error is a non-empty string, the string itself is used as the message. + * Otherwise, if the error is an `Error` with a non-empty message, its message, stack trace, and cause + * are extracted; causes are extracted recursively up to a fixed depth, ignoring cycles. + * + * @param error The error to extract the details. + * @param defaultMessage The default message if the error is not a non-empty string or an `Error` with a message. + * + * @returns The extracted details, including the stack trace and the cause, when available. + */ +export function extractErrorDetails(error: unknown, defaultMessage = 'Unknown error'): ErrorDetails { + return extractErrorDetailsAtDepth(error, defaultMessage, 0, []); +} + +const MAX_ERROR_CAUSE_DEPTH = 10; + +function extractErrorDetailsAtDepth( + error: unknown, + defaultMessage: string, + depth: number, + ancestors: Array, +): ErrorDetails { + if (typeof error === 'string' && error !== '') { + return {message: error}; + } + + if ( + typeof error !== 'object' + || !(error instanceof Error) + || error.message === '' + ) { + return {message: defaultMessage}; + } + + const details: ErrorDetails = {message: error.message}; + + if (error.stack !== undefined) { + details.stack = error.stack; + } + + ancestors.push(error); + + if ( + error.cause !== undefined + && depth < MAX_ERROR_CAUSE_DEPTH + && !ancestors.includes(error.cause) + ) { + details.cause = extractErrorDetailsAtDepth(error.cause, defaultMessage, depth + 1, ancestors); + } + + return details; +} diff --git a/test/utilities.test.ts b/test/utilities.test.ts index 47321ca..885fcc5 100644 --- a/test/utilities.test.ts +++ b/test/utilities.test.ts @@ -1,5 +1,4 @@ -/* eslint-disable no-console -- Needed for testing */ -import {extractErrorMessage} from '../src'; +import {ErrorDetails, extractErrorDetails, extractErrorMessage} from '../src'; describe('A function for extracting error messages', () => { it.each<[any, string|undefined, string]>([ @@ -20,3 +19,122 @@ describe('A function for extracting error messages', () => { expect(extractErrorMessage(error, defaultMessage)).toBe(message); }); }); + +describe('A function for extracting error details', () => { + it.each<[unknown, string|undefined, ErrorDetails]>([ + ['Error message.', undefined, {message: 'Error message.'}], + ['', undefined, {message: 'Unknown error'}], + ['', 'Custom default message', {message: 'Custom default message'}], + [null, undefined, {message: 'Unknown error'}], + [null, 'Custom default message', {message: 'Custom default message'}], + [true, undefined, {message: 'Unknown error'}], + [{}, undefined, {message: 'Unknown error'}], + [new Error(''), undefined, {message: 'Unknown error'}], + [new Error(''), 'Custom default message', {message: 'Custom default message'}], + ])('should extract the details of the error %p as %j', ( + error: unknown, + defaultMessage: string | undefined, + details: ErrorDetails, + ) => { + expect(extractErrorDetails(error, defaultMessage)).toEqual(details); + }); + + it('should extract the message and stack of an error', () => { + const error = new Error('Error message.'); + + error.stack = 'Error: Error message.\n at :1:1'; + + expect(extractErrorDetails(error)).toEqual({ + message: 'Error message.', + stack: 'Error: Error message.\n at :1:1', + }); + }); + + it('should omit the stack when the error has none', () => { + const error = new Error('Error message.'); + + delete error.stack; + + expect(extractErrorDetails(error)).toEqual({message: 'Error message.'}); + }); + + it('should recursively extract the details of the cause', () => { + const cause = new Error('Cause message.'); + const error = new Error('Error message.'); + + cause.stack = 'Error: Cause message.\n at :1:1'; + error.stack = 'Error: Error message.\n at :2:2'; + error.cause = cause; + + expect(extractErrorDetails(error)).toEqual({ + message: 'Error message.', + stack: 'Error: Error message.\n at :2:2', + cause: { + message: 'Cause message.', + stack: 'Error: Cause message.\n at :1:1', + }, + }); + }); + + it('should extract the message of a string cause', () => { + const error = new Error('Error message.'); + + error.stack = 'Error: Error message.\n at :1:1'; + error.cause = 'Cause message.'; + + expect(extractErrorDetails(error)).toEqual({ + message: 'Error message.', + stack: 'Error: Error message.\n at :1:1', + cause: {message: 'Cause message.'}, + }); + }); + it('should ignore a circular cause', () => { + const error = new Error('Error message.'); + const cause = new Error('Cause message.'); + + delete error.stack; + delete cause.stack; + error.cause = cause; + cause.cause = error; + + expect(extractErrorDetails(error)).toEqual({ + message: 'Error message.', + cause: {message: 'Cause message.'}, + }); + }); + + it('should limit the cause recursion depth', () => { + const errors = Array.from({length: 12}, (_, index) => new Error(`Error ${index}.`)); + + for (let index = 0; index < errors.length - 1; index++) { + errors[index].cause = errors[index + 1]; + } + + let details: ErrorDetails | undefined = extractErrorDetails(errors[0]); + const messages: string[] = []; + + while (details !== undefined) { + messages.push(details.message); + details = details.cause; + } + + expect(messages).toEqual(Array.from({length: 11}, (_, index) => `Error ${index}.`)); + }); + + it.each<[unknown, string|undefined, string]>([ + [new Error(''), undefined, 'Unknown error'], + [new Error(''), 'Custom default message', 'Custom default message'], + [{}, undefined, 'Unknown error'], + [{}, 'Custom default message', 'Custom default message'], + ])('should fall back to "%s" for the unsupported cause %p', ( + cause: unknown, + defaultMessage: string | undefined, + causeMessage: string, + ) => { + const error = new Error('Error message.'); + + error.cause = cause; + + expect(extractErrorDetails(error, defaultMessage).cause).toEqual({message: causeMessage}); + }); +});