Skip to content

Commit 2263f9b

Browse files
mcmireMajorLiftlegobeat
authored
Add error utils (#146)
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>
1 parent f5b86cc commit 2263f9b

5 files changed

Lines changed: 429 additions & 25 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@scure/base": "^1.1.3",
5353
"@types/debug": "^4.1.7",
5454
"debug": "^4.3.4",
55+
"pony-cause": "^2.1.10",
5556
"semver": "^7.5.4",
5657
"superstruct": "^1.0.3"
5758
},

src/assert.ts

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,12 @@
11
import type { Struct } from 'superstruct';
22
import { assert as assertSuperstruct } from 'superstruct';
33

4+
import { getErrorMessage } from './errors';
5+
46
export type AssertionErrorConstructor =
57
| (new (args: { message: string }) => Error)
68
| ((args: { message: string }) => Error);
79

8-
/**
9-
* Type guard for determining whether the given value is an error object with a
10-
* `message` property, such as an instance of Error.
11-
*
12-
* @param error - The object to check.
13-
* @returns True or false, depending on the result.
14-
*/
15-
function isErrorWithMessage(error: unknown): error is { message: string } {
16-
return typeof error === 'object' && error !== null && 'message' in error;
17-
}
18-
1910
/**
2011
* Check if a value is a constructor, i.e., a function that can be called with
2112
* the `new` keyword.
@@ -31,22 +22,18 @@ function isConstructable(
3122
}
3223

3324
/**
34-
* Get the error message from an unknown error object. If the error object has
35-
* a `message` property, that property is returned. Otherwise, the stringified
36-
* error object is returned.
25+
* Attempts to obtain the message from a possible error object. If it is
26+
* possible to do so, any trailing period will be removed from the message;
27+
* otherwise an empty string is returned.
3728
*
3829
* @param error - The error object to get the message from.
39-
* @returns The error message.
30+
* @returns The message without any trailing period if `error` is an object
31+
* with a `message` property; the string version of `error` without any trailing
32+
* period if it is not `undefined` or `null`; otherwise an empty string.
4033
*/
41-
function getErrorMessage(error: unknown): string {
42-
const message = isErrorWithMessage(error) ? error.message : String(error);
43-
44-
// If the error ends with a period, remove it, as we'll add our own period.
45-
if (message.endsWith('.')) {
46-
return message.slice(0, -1);
47-
}
48-
49-
return message;
34+
function getErrorMessageWithoutTrailingPeriod(error: unknown): string {
35+
// We'll add our own period.
36+
return getErrorMessage(error).replace(/\.$/u, '');
5037
}
5138

5239
/**
@@ -127,7 +114,10 @@ export function assertStruct<Type, Schema>(
127114
try {
128115
assertSuperstruct(value, struct);
129116
} catch (error) {
130-
throw getError(ErrorWrapper, `${errorPrefix}: ${getErrorMessage(error)}.`);
117+
throw getError(
118+
ErrorWrapper,
119+
`${errorPrefix}: ${getErrorMessageWithoutTrailingPeriod(error)}.`,
120+
);
131121
}
132122
}
133123

src/errors.test.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import fs from 'fs';
2+
3+
import {
4+
getErrorMessage,
5+
isErrorWithCode,
6+
isErrorWithMessage,
7+
isErrorWithStack,
8+
wrapError,
9+
} from './errors';
10+
11+
describe('isErrorWithCode', () => {
12+
it('returns true if given an object that includes a "code" property', () => {
13+
expect(
14+
isErrorWithCode({ code: 'some code', message: 'some message' }),
15+
).toBe(true);
16+
});
17+
18+
it('returns false if given null', () => {
19+
expect(isErrorWithCode(null)).toBe(false);
20+
});
21+
22+
it('returns false if given undefined', () => {
23+
expect(isErrorWithCode(undefined)).toBe(false);
24+
});
25+
26+
it('returns false if given something that is not typeof object', () => {
27+
expect(isErrorWithCode(12345)).toBe(false);
28+
});
29+
30+
it('returns false if given an empty object', () => {
31+
expect(isErrorWithCode({})).toBe(false);
32+
});
33+
34+
it('returns false if given a non-empty object that does not have a "code" property', () => {
35+
expect(isErrorWithCode({ message: 'some message' })).toBe(false);
36+
});
37+
});
38+
39+
describe('isErrorWithMessage', () => {
40+
it('returns true if given an object that includes a "message" property', () => {
41+
expect(
42+
isErrorWithMessage({ code: 'some code', message: 'some message' }),
43+
).toBe(true);
44+
});
45+
46+
it('returns false if given null', () => {
47+
expect(isErrorWithMessage(null)).toBe(false);
48+
});
49+
50+
it('returns false if given undefined', () => {
51+
expect(isErrorWithMessage(undefined)).toBe(false);
52+
});
53+
54+
it('returns false if given something that is not typeof object', () => {
55+
expect(isErrorWithMessage(12345)).toBe(false);
56+
});
57+
58+
it('returns false if given an empty object', () => {
59+
expect(isErrorWithMessage({})).toBe(false);
60+
});
61+
62+
it('returns false if given a non-empty object that does not have a "message" property', () => {
63+
expect(isErrorWithMessage({ code: 'some code' })).toBe(false);
64+
});
65+
});
66+
67+
describe('isErrorWithStack', () => {
68+
it('returns true if given an object that includes a "stack" property', () => {
69+
expect(isErrorWithStack({ code: 'some code', stack: 'some stack' })).toBe(
70+
true,
71+
);
72+
});
73+
74+
it('returns false if given null', () => {
75+
expect(isErrorWithStack(null)).toBe(false);
76+
});
77+
78+
it('returns false if given undefined', () => {
79+
expect(isErrorWithStack(undefined)).toBe(false);
80+
});
81+
82+
it('returns false if given something that is not typeof object', () => {
83+
expect(isErrorWithStack(12345)).toBe(false);
84+
});
85+
86+
it('returns false if given an empty object', () => {
87+
expect(isErrorWithStack({})).toBe(false);
88+
});
89+
90+
it('returns false if given a non-empty object that does not have a "stack" property', () => {
91+
expect(
92+
isErrorWithStack({ code: 'some code', message: 'some message' }),
93+
).toBe(false);
94+
});
95+
});
96+
97+
describe('wrapError', () => {
98+
describe('if the original error is an Error instance not generated by fs.promises', () => {
99+
it('returns a new Error with the given message', () => {
100+
const originalError = new Error('oops');
101+
102+
const newError = wrapError(originalError, 'Some message');
103+
104+
expect(newError.message).toBe('Some message');
105+
});
106+
107+
it('links to the original error via "cause"', () => {
108+
const originalError = new Error('oops');
109+
110+
const newError = wrapError(originalError, 'Some message');
111+
112+
expect(newError.cause).toBe(originalError);
113+
});
114+
115+
it('copies over any "code" property that exists on the given Error', () => {
116+
const originalError = new Error('oops');
117+
// @ts-expect-error The Error interface doesn't have a "code" property
118+
originalError.code = 'CODE';
119+
120+
const newError = wrapError(originalError, 'Some message');
121+
122+
expect(newError.code).toBe('CODE');
123+
});
124+
});
125+
126+
describe('if the original error was generated by fs.promises', () => {
127+
it('returns a new Error with the given message', async () => {
128+
let originalError;
129+
try {
130+
await fs.promises.readFile('/tmp/nonexistent', 'utf8');
131+
} catch (error: any) {
132+
originalError = error;
133+
}
134+
135+
const newError = wrapError(originalError, 'Some message');
136+
137+
expect(newError.message).toBe('Some message');
138+
});
139+
140+
it("links to the original error via 'cause'", async () => {
141+
let originalError;
142+
try {
143+
await fs.promises.readFile('/tmp/nonexistent', 'utf8');
144+
} catch (error: any) {
145+
originalError = error;
146+
}
147+
148+
const newError = wrapError(originalError, 'Some message');
149+
150+
expect(newError.cause).toBe(originalError);
151+
});
152+
153+
it('copies over any "code" property that exists on the given Error', async () => {
154+
let originalError;
155+
try {
156+
await fs.promises.readFile('/tmp/nonexistent', 'utf8');
157+
} catch (error: any) {
158+
originalError = error;
159+
}
160+
161+
const newError = wrapError(originalError, 'Some message');
162+
163+
expect(newError.code).toBe('ENOENT');
164+
});
165+
});
166+
167+
describe('if the original error is an object but not an Error instance', () => {
168+
describe('if the message is a non-empty string', () => {
169+
it('combines a string version of the original error and message together in a new Error', () => {
170+
const originalError = { some: 'error' };
171+
172+
const newError = wrapError(originalError, 'Some message');
173+
174+
expect(newError.message).toBe('[object Object]: Some message');
175+
});
176+
177+
it('does not set a cause on the new Error', async () => {
178+
const originalError = { some: 'error' };
179+
180+
const newError = wrapError(originalError, 'Some message');
181+
182+
expect(newError.cause).toBeUndefined();
183+
});
184+
185+
it('does not set a code on the new Error', async () => {
186+
const originalError = { some: 'error' };
187+
188+
const newError = wrapError(originalError, 'Some message');
189+
190+
expect(newError.code).toBeUndefined();
191+
});
192+
});
193+
194+
describe('if the message is an empty string', () => {
195+
it('places a string version of the original error in a new Error object without an additional message', () => {
196+
const originalError = { some: 'error' };
197+
198+
const newError = wrapError(originalError, '');
199+
200+
expect(newError.message).toBe('[object Object]');
201+
});
202+
203+
it('does not set a cause on the new Error', async () => {
204+
const originalError = { some: 'error' };
205+
206+
const newError = wrapError(originalError, '');
207+
208+
expect(newError.cause).toBeUndefined();
209+
});
210+
211+
it('does not set a code on the new Error', async () => {
212+
const originalError = { some: 'error' };
213+
214+
const newError = wrapError(originalError, '');
215+
216+
expect(newError.code).toBeUndefined();
217+
});
218+
});
219+
});
220+
221+
describe('if the original error is a string', () => {
222+
describe('if the message is a non-empty string', () => {
223+
it('combines the original error and message together in a new Error', () => {
224+
const newError = wrapError('Some original message', 'Some message');
225+
226+
expect(newError.message).toBe('Some original message: Some message');
227+
});
228+
229+
it('does not set a cause on the new Error', () => {
230+
const newError = wrapError('Some original message', 'Some message');
231+
232+
expect(newError.cause).toBeUndefined();
233+
});
234+
235+
it('does not set a code on the new Error', () => {
236+
const newError = wrapError('Some original message', 'Some message');
237+
238+
expect(newError.code).toBeUndefined();
239+
});
240+
});
241+
242+
describe('if the message is an empty string', () => {
243+
it('places the original error in a new Error object without an additional message', () => {
244+
const newError = wrapError('Some original message', '');
245+
246+
expect(newError.message).toBe('Some original message');
247+
});
248+
249+
it('does not set a cause on the new Error', () => {
250+
const newError = wrapError('Some original message', '');
251+
252+
expect(newError.cause).toBeUndefined();
253+
});
254+
255+
it('does not set a code on the new Error', () => {
256+
const newError = wrapError('Some original message', '');
257+
258+
expect(newError.code).toBeUndefined();
259+
});
260+
});
261+
});
262+
});
263+
264+
describe('getErrorMessage', () => {
265+
it("returns the value of the 'message' property from the given object if it is present", () => {
266+
expect(getErrorMessage({ message: 'hello' })).toBe('hello');
267+
});
268+
269+
it("returns the result of calling .toString() on the given object if it has no 'message' property", () => {
270+
expect(getErrorMessage({ foo: 'bar' })).toBe('[object Object]');
271+
});
272+
273+
it('returns the result of calling .toString() on the given non-object', () => {
274+
expect(getErrorMessage(42)).toBe('42');
275+
});
276+
277+
it('returns an empty string if given null', () => {
278+
expect(getErrorMessage(null)).toBe('');
279+
});
280+
281+
it('returns an empty string if given undefined', () => {
282+
expect(getErrorMessage(undefined)).toBe('');
283+
});
284+
});

0 commit comments

Comments
 (0)