diff --git a/package.json b/package.json index 67e922f9..d8659ade 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", + "pony-cause": "^2.1.10", "semver": "^7.5.4", "superstruct": "^1.0.3" }, diff --git a/src/assert.ts b/src/assert.ts index 0e151ecd..5892ebbf 100644 --- a/src/assert.ts +++ b/src/assert.ts @@ -1,21 +1,12 @@ import type { Struct } from 'superstruct'; import { assert as assertSuperstruct } from 'superstruct'; +import { getErrorMessage } from './errors'; + export type AssertionErrorConstructor = | (new (args: { message: string }) => Error) | ((args: { message: string }) => Error); -/** - * Type guard for determining whether the given value is an error object with a - * `message` property, such as an instance of Error. - * - * @param error - The object to check. - * @returns True or false, depending on the result. - */ -function isErrorWithMessage(error: unknown): error is { message: string } { - return typeof error === 'object' && error !== null && 'message' in error; -} - /** * Check if a value is a constructor, i.e., a function that can be called with * the `new` keyword. @@ -31,22 +22,18 @@ function isConstructable( } /** - * Get the error message from an unknown error object. If the error object has - * a `message` property, that property is returned. Otherwise, the stringified - * error object is returned. + * Attempts to obtain the message from a possible error object. If it is + * possible to do so, any trailing period will be removed from the message; + * otherwise an empty string is returned. * * @param error - The error object to get the message from. - * @returns The error message. + * @returns The message without any trailing period if `error` is an object + * with a `message` property; the string version of `error` without any trailing + * period if it is not `undefined` or `null`; otherwise an empty string. */ -function getErrorMessage(error: unknown): string { - const message = isErrorWithMessage(error) ? error.message : String(error); - - // If the error ends with a period, remove it, as we'll add our own period. - if (message.endsWith('.')) { - return message.slice(0, -1); - } - - return message; +function getErrorMessageWithoutTrailingPeriod(error: unknown): string { + // We'll add our own period. + return getErrorMessage(error).replace(/\.$/u, ''); } /** @@ -127,7 +114,10 @@ export function assertStruct( try { assertSuperstruct(value, struct); } catch (error) { - throw getError(ErrorWrapper, `${errorPrefix}: ${getErrorMessage(error)}.`); + throw getError( + ErrorWrapper, + `${errorPrefix}: ${getErrorMessageWithoutTrailingPeriod(error)}.`, + ); } } diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 00000000..a5cdd2d9 --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,284 @@ +import fs from 'fs'; + +import { + getErrorMessage, + isErrorWithCode, + isErrorWithMessage, + isErrorWithStack, + wrapError, +} from './errors'; + +describe('isErrorWithCode', () => { + it('returns true if given an object that includes a "code" property', () => { + expect( + isErrorWithCode({ code: 'some code', message: 'some message' }), + ).toBe(true); + }); + + it('returns false if given null', () => { + expect(isErrorWithCode(null)).toBe(false); + }); + + it('returns false if given undefined', () => { + expect(isErrorWithCode(undefined)).toBe(false); + }); + + it('returns false if given something that is not typeof object', () => { + expect(isErrorWithCode(12345)).toBe(false); + }); + + it('returns false if given an empty object', () => { + expect(isErrorWithCode({})).toBe(false); + }); + + it('returns false if given a non-empty object that does not have a "code" property', () => { + expect(isErrorWithCode({ message: 'some message' })).toBe(false); + }); +}); + +describe('isErrorWithMessage', () => { + it('returns true if given an object that includes a "message" property', () => { + expect( + isErrorWithMessage({ code: 'some code', message: 'some message' }), + ).toBe(true); + }); + + it('returns false if given null', () => { + expect(isErrorWithMessage(null)).toBe(false); + }); + + it('returns false if given undefined', () => { + expect(isErrorWithMessage(undefined)).toBe(false); + }); + + it('returns false if given something that is not typeof object', () => { + expect(isErrorWithMessage(12345)).toBe(false); + }); + + it('returns false if given an empty object', () => { + expect(isErrorWithMessage({})).toBe(false); + }); + + it('returns false if given a non-empty object that does not have a "message" property', () => { + expect(isErrorWithMessage({ code: 'some code' })).toBe(false); + }); +}); + +describe('isErrorWithStack', () => { + it('returns true if given an object that includes a "stack" property', () => { + expect(isErrorWithStack({ code: 'some code', stack: 'some stack' })).toBe( + true, + ); + }); + + it('returns false if given null', () => { + expect(isErrorWithStack(null)).toBe(false); + }); + + it('returns false if given undefined', () => { + expect(isErrorWithStack(undefined)).toBe(false); + }); + + it('returns false if given something that is not typeof object', () => { + expect(isErrorWithStack(12345)).toBe(false); + }); + + it('returns false if given an empty object', () => { + expect(isErrorWithStack({})).toBe(false); + }); + + it('returns false if given a non-empty object that does not have a "stack" property', () => { + expect( + isErrorWithStack({ code: 'some code', message: 'some message' }), + ).toBe(false); + }); +}); + +describe('wrapError', () => { + describe('if the original error is an Error instance not generated by fs.promises', () => { + it('returns a new Error with the given message', () => { + const originalError = new Error('oops'); + + const newError = wrapError(originalError, 'Some message'); + + expect(newError.message).toBe('Some message'); + }); + + it('links to the original error via "cause"', () => { + const originalError = new Error('oops'); + + const newError = wrapError(originalError, 'Some message'); + + expect(newError.cause).toBe(originalError); + }); + + it('copies over any "code" property that exists on the given Error', () => { + const originalError = new Error('oops'); + // @ts-expect-error The Error interface doesn't have a "code" property + originalError.code = 'CODE'; + + const newError = wrapError(originalError, 'Some message'); + + expect(newError.code).toBe('CODE'); + }); + }); + + describe('if the original error was generated by fs.promises', () => { + it('returns a new Error with the given message', async () => { + let originalError; + try { + await fs.promises.readFile('/tmp/nonexistent', 'utf8'); + } catch (error: any) { + originalError = error; + } + + const newError = wrapError(originalError, 'Some message'); + + expect(newError.message).toBe('Some message'); + }); + + it("links to the original error via 'cause'", async () => { + let originalError; + try { + await fs.promises.readFile('/tmp/nonexistent', 'utf8'); + } catch (error: any) { + originalError = error; + } + + const newError = wrapError(originalError, 'Some message'); + + expect(newError.cause).toBe(originalError); + }); + + it('copies over any "code" property that exists on the given Error', async () => { + let originalError; + try { + await fs.promises.readFile('/tmp/nonexistent', 'utf8'); + } catch (error: any) { + originalError = error; + } + + const newError = wrapError(originalError, 'Some message'); + + expect(newError.code).toBe('ENOENT'); + }); + }); + + describe('if the original error is an object but not an Error instance', () => { + describe('if the message is a non-empty string', () => { + it('combines a string version of the original error and message together in a new Error', () => { + const originalError = { some: 'error' }; + + const newError = wrapError(originalError, 'Some message'); + + expect(newError.message).toBe('[object Object]: Some message'); + }); + + it('does not set a cause on the new Error', async () => { + const originalError = { some: 'error' }; + + const newError = wrapError(originalError, 'Some message'); + + expect(newError.cause).toBeUndefined(); + }); + + it('does not set a code on the new Error', async () => { + const originalError = { some: 'error' }; + + const newError = wrapError(originalError, 'Some message'); + + expect(newError.code).toBeUndefined(); + }); + }); + + describe('if the message is an empty string', () => { + it('places a string version of the original error in a new Error object without an additional message', () => { + const originalError = { some: 'error' }; + + const newError = wrapError(originalError, ''); + + expect(newError.message).toBe('[object Object]'); + }); + + it('does not set a cause on the new Error', async () => { + const originalError = { some: 'error' }; + + const newError = wrapError(originalError, ''); + + expect(newError.cause).toBeUndefined(); + }); + + it('does not set a code on the new Error', async () => { + const originalError = { some: 'error' }; + + const newError = wrapError(originalError, ''); + + expect(newError.code).toBeUndefined(); + }); + }); + }); + + describe('if the original error is a string', () => { + describe('if the message is a non-empty string', () => { + it('combines the original error and message together in a new Error', () => { + const newError = wrapError('Some original message', 'Some message'); + + expect(newError.message).toBe('Some original message: Some message'); + }); + + it('does not set a cause on the new Error', () => { + const newError = wrapError('Some original message', 'Some message'); + + expect(newError.cause).toBeUndefined(); + }); + + it('does not set a code on the new Error', () => { + const newError = wrapError('Some original message', 'Some message'); + + expect(newError.code).toBeUndefined(); + }); + }); + + describe('if the message is an empty string', () => { + it('places the original error in a new Error object without an additional message', () => { + const newError = wrapError('Some original message', ''); + + expect(newError.message).toBe('Some original message'); + }); + + it('does not set a cause on the new Error', () => { + const newError = wrapError('Some original message', ''); + + expect(newError.cause).toBeUndefined(); + }); + + it('does not set a code on the new Error', () => { + const newError = wrapError('Some original message', ''); + + expect(newError.code).toBeUndefined(); + }); + }); + }); +}); + +describe('getErrorMessage', () => { + it("returns the value of the 'message' property from the given object if it is present", () => { + expect(getErrorMessage({ message: 'hello' })).toBe('hello'); + }); + + it("returns the result of calling .toString() on the given object if it has no 'message' property", () => { + expect(getErrorMessage({ foo: 'bar' })).toBe('[object Object]'); + }); + + it('returns the result of calling .toString() on the given non-object', () => { + expect(getErrorMessage(42)).toBe('42'); + }); + + it('returns an empty string if given null', () => { + expect(getErrorMessage(null)).toBe(''); + }); + + it('returns an empty string if given undefined', () => { + expect(getErrorMessage(undefined)).toBe(''); + }); +}); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 00000000..40d03d42 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,121 @@ +import { ErrorWithCause } from 'pony-cause'; + +import { isNullOrUndefined, isObject } from './misc'; + +/** + * Type guard for determining whether the given value is an instance of Error. + * For errors generated via `fs.promises`, `error instanceof Error` won't work, + * so we have to come up with another way of testing. + * + * @param error - The object to check. + * @returns A boolean. + */ +function isError(error: unknown): error is Error { + return ( + error instanceof Error || + (isObject(error) && error.constructor.name === 'Error') + ); +} + +/** + * Type guard for determining whether the given value is an error object with a + * `code` property such as the type of error that Node throws for filesystem + * operations, etc. + * + * @param error - The object to check. + * @returns A boolean. + */ +export function isErrorWithCode(error: unknown): error is { code: string } { + return typeof error === 'object' && error !== null && 'code' in error; +} + +/** + * Type guard for determining whether the given value is an error object with a + * `message` property, such as an instance of Error. + * + * @param error - The object to check. + * @returns A boolean. + */ +export function isErrorWithMessage( + error: unknown, +): error is { message: string } { + return typeof error === 'object' && error !== null && 'message' in error; +} + +/** + * Type guard for determining whether the given value is an error object with a + * `stack` property, such as an instance of Error. + * + * @param error - The object to check. + * @returns A boolean. + */ +export function isErrorWithStack(error: unknown): error is { stack: string } { + return typeof error === 'object' && error !== null && 'stack' in error; +} + +/** + * Attempts to obtain the message from a possible error object, defaulting to an + * empty string if it is impossible to do so. + * + * @param error - The possible error to get the message from. + * @returns The message if `error` is an object with a `message` property; + * the string version of `error` if it is not `undefined` or `null`; otherwise + * an empty string. + */ +export function getErrorMessage(error: unknown): string { + if (isErrorWithMessage(error) && typeof error.message === 'string') { + return error.message; + } + + if (isNullOrUndefined(error)) { + return ''; + } + + return String(error); +} + +/** + * Builds a new error object, linking it to the original error via the `cause` + * property if it is an Error. + * + * This function is useful to reframe error messages in general, but is + * _critical_ when interacting with any of Node's filesystem functions as + * provided via `fs.promises`, because these do not produce stack traces in the + * case of an I/O error (see ). + * + * @param originalError - The error to be wrapped (something throwable). + * @param message - The desired message of the new error. + * @returns A new error object. + */ +export function wrapError( + originalError: Throwable, + message: string, +): Error & { code?: string } { + if (isError(originalError)) { + const error: Error & { code?: string } = + Error.length === 2 + ? // This branch is getting tested by using the Node version that + // supports `cause` on the Error constructor. + // istanbul ignore next + // Also, for some reason `tsserver` is not complaining that the + // Error constructor doesn't support a second argument in the editor, + // but `tsc` does. I'm not sure why, but we disable this in the + // meantime. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + new Error(message, { cause: originalError }) + : new ErrorWithCause(message, { cause: originalError }); + + if (isErrorWithCode(originalError)) { + error.code = originalError.code; + } + + return error; + } + + if (message.length > 0) { + return new Error(`${String(originalError)}: ${message}`); + } + + return new Error(String(originalError)); +} diff --git a/yarn.lock b/yarn.lock index 23ccc2ef..b1955d40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1241,6 +1241,7 @@ __metadata: eslint-plugin-promise: ^6.1.1 jest: ^29.2.2 jest-it-up: ^2.0.2 + pony-cause: ^2.1.10 prettier: ^2.7.1 prettier-plugin-packagejson: ^2.3.0 semver: ^7.5.4 @@ -6182,6 +6183,13 @@ __metadata: languageName: node linkType: hard +"pony-cause@npm:^2.1.10": + version: 2.1.10 + resolution: "pony-cause@npm:2.1.10" + checksum: 8b61378f213e61056312dc274a1c79980154e9d864f6ad86e0c8b91a50d3ce900d430995ee24147c9f3caa440dfe7d51c274b488d7f033b65b206522536d7217 + languageName: node + linkType: hard + "postcss-load-config@npm:^4.0.1": version: 4.0.1 resolution: "postcss-load-config@npm:4.0.1"