Skip to content

Commit

Permalink
Add error utils (#146)
Browse files Browse the repository at this point in the history
This commit satisfies a few needs that we have in various projects:

- When catching a throwable from some kind of operation, we want to
  be able to test whether the throwable is an error.
- Furthermore, since the Error interface in TypeScript is pretty simple,
  we want to be able to test for different properties on an error
  (`code`, `stack`, etc.).
- We want to wrap an error produced by a lower level part of the system
  with a different message, but preserve the original error using the
  `cause` property (note: this property was added in Node 18, so for
  older Nodes, we use the `pony-cause` library to set this).
- We want to be able to take a throwable and produce an error that has a
  stacktrace. This is particularly useful for working with the
  `fs.promises` module, which (as of Node 22) [does not produce proper
  stacktraces][1].
- We want to be able to get a message from a throwable.

[1]: nodejs/node#30944

---------

Co-authored-by: Jongsun Suh <34228073+MajorLift@users.noreply.github.com>
Co-authored-by: legobeat <109787230+legobeat@users.noreply.github.com>
  • Loading branch information
3 people authored Oct 17, 2023
1 parent f5b86cc commit 2263f9b
Show file tree
Hide file tree
Showing 5 changed files with 429 additions and 25 deletions.
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
40 changes: 15 additions & 25 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,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, '');
}

/**
Expand Down Expand Up @@ -127,7 +114,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
284 changes: 284 additions & 0 deletions src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
Loading

0 comments on commit 2263f9b

Please sign in to comment.