Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add error utils #146

Merged
merged 13 commits into from
Oct 17, 2023
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
34 changes: 15 additions & 19 deletions src/assert.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -31,18 +22,20 @@ 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);
function getErrorMessageWithoutTrailingPeriod(error: unknown): string {
const message = getErrorMessage(error);

// If the error ends with a period, remove it, as we'll add our own period.
if (message.endsWith('.')) {
// We'll add our own period.
return message.slice(0, -1);
}
mcmire marked this conversation as resolved.
Show resolved Hide resolved

Expand Down Expand Up @@ -127,7 +120,10 @@ export function assertStruct<Type, Schema>(
try {
assertSuperstruct(value, struct);
} catch (error) {
throw getError(ErrorWrapper, `${errorPrefix}: ${getErrorMessage(error)}.`);
throw getError(
ErrorWrapper,
`${errorPrefix}: ${getErrorMessageWithoutTrailingPeriod(error)}.`,
);
}
}

Expand Down
179 changes: 179 additions & 0 deletions src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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('given an Error', () => {
it('returns a new Error with the given message that links to the Error via "cause"', () => {
const originalError = new Error('oops');
const newError = wrapError(originalError, 'Some message');

expect(newError.message).toBe('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('given an Error generated by fs.promises', () => {
it('returns a new Error with the given message that links to the 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.message).toBe('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('given a string', () => {
it("treats it as a prefix for the new Error's message", () => {
const newError = wrapError('Some original message', 'Some message');

expect(newError.message).toBe('Some message: Some original message');
});

it('does not set a cause on the new Error', () => {
const newError = wrapError('Some original message', 'Some message');

expect(newError.cause).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('');
});
});
117 changes: 117 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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 <https://github.com/nodejs/node/issues/30944>).
*
* @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<Throwable>(
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;
}

return new Error(`${message}: ${String(originalError)}`);
}
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,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
Expand Down Expand Up @@ -6215,6 +6216,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"
Expand Down