diff --git a/error-constructors.d.ts b/error-constructors.d.ts new file mode 100644 index 0000000..b3e8017 --- /dev/null +++ b/error-constructors.d.ts @@ -0,0 +1,8 @@ +/** +Map of error constructors to recreate from the serialize `name` property. If the name is not found in this map, the errors will be deserialized as simple `Error` instances. + +Warning: Only simple and standard error constructors are supported, like `new MyCustomError(name)`. If your error constructor *requires* a second parameter or does not accept a string as first parameter, adding it to this map *will* break the deserialization. +*/ +declare const errorConstructors: Map; + +export default errorConstructors; diff --git a/error-constructors.js b/error-constructors.js new file mode 100644 index 0000000..bdad603 --- /dev/null +++ b/error-constructors.js @@ -0,0 +1,26 @@ +const list = [ + // Native ES errors https://262.ecma-international.org/12.0/#sec-well-known-intrinsic-objects + EvalError, + RangeError, + ReferenceError, + SyntaxError, + TypeError, + URIError, + + // Built-in errors + globalThis.DOMException, + + // Node-specific errors + // https://nodejs.org/api/errors.html + globalThis.AssertionError, + globalThis.SystemError, +] + // Non-native Errors are used with `globalThis` because they might be missing. This filter drops them when undefined. + .filter(Boolean) + .map( + constructor => [constructor.name, constructor], + ); + +const errorConstructors = new Map(list); + +export default errorConstructors; diff --git a/index.d.ts b/index.d.ts index 55ec263..dff06e7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,7 @@ import {Primitive, JsonObject} from 'type-fest'; +export {default as errorConstructors} from './error-constructors.js'; + export type ErrorObject = { name?: string; message?: string; diff --git a/index.js b/index.js index 64bf85f..f39a290 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,5 @@ +import errorConstructors from './error-constructors.js'; + export class NonError extends Error { name = 'NonError'; @@ -46,6 +48,8 @@ const toJSON = from => { return json; }; +const getErrorConstructor = name => errorConstructors.get(name) || Error; + // eslint-disable-next-line complexity const destroyCircular = ({ from, @@ -68,16 +72,19 @@ const destroyCircular = ({ return toJSON(from); } - const destroyLocal = value => destroyCircular({ - from: value, - seen: [...seen], - // eslint-disable-next-line unicorn/error-message - to_: isErrorLike(value) ? new Error() : undefined, - forceEnumerable, - maxDepth, - depth, - useToJSON, - }); + const destroyLocal = value => { + const Error = getErrorConstructor(value.name); + return destroyCircular({ + from: value, + seen: [...seen], + + to_: isErrorLike(value) ? new Error() : undefined, + forceEnumerable, + maxDepth, + depth, + useToJSON, + }); + }; for (const [key, value] of Object.entries(from)) { // eslint-disable-next-line node/prefer-global/buffer @@ -159,10 +166,10 @@ export function deserializeError(value, options = {}) { } if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const Error = getErrorConstructor(value.name); return destroyCircular({ from: value, seen: [], - // eslint-disable-next-line unicorn/error-message to_: new Error(), maxDepth, depth: 0, @@ -179,3 +186,5 @@ export function isErrorLike(value) { && 'message' in value && 'stack' in value; } + +export {errorConstructors}; diff --git a/package.json b/package.json index 8c49fc2..1c0ccfc 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ }, "files": [ "index.js", - "index.d.ts" + "index.d.ts", + "error-constructors.js", + "error-constructors.d.ts" ], "keywords": [ "error", diff --git a/readme.md b/readme.md index 0fa3944..ddceae0 100644 --- a/readme.md +++ b/readme.md @@ -31,6 +31,41 @@ console.log(deserialized); //=> [Error: 🦄] ``` +### Error constructors + +When a serialized error with a known `name` is encountered, it will be deserialized using the corresponding error constructor, while enknown error names will be deserialized as regular errors: + +```js +import {deserializeError} from 'serialize-error'; + +const known = deserializeError({ + name: 'TypeError', + message: '🦄' +}); + +console.log(known); +//=> [TypeError: 🦄] <-- still a TypeError + +const unknown = deserializeError({ + name: 'TooManyCooksError', + message: '🦄' +}); + +console.log(unknown); +//=> [Error: 🦄] <-- just a regular Error +``` + +The [list of known errors](./error-constructors.js) can be extended globally. This also works if `serialize-error` is a sub-dependency that's not used directly. + +```js +import {errorConstructors} from 'serialize-error'; +import {MyCustomError} from './errors.js' + +errorConstructors.set('MyCustomError', MyCustomError) +``` + +**Warning:** Only simple and standard error constructors are supported, like `new MyCustomError(name)`. If your error constructor **requires** a second parameter or does not accept a string as first parameter, adding it to this map **will** break the deserialization. + ## API ### serializeError(value, options?) @@ -93,6 +128,7 @@ Deserialize a plain object or any value into an `Error` object. - Non-enumerable properties are kept non-enumerable (name, message, stack, cause). - Enumerable properties are kept enumerable (all properties besides the non-enumerable ones). - Circular references are handled. +- [Native error constructors](./error-constructors.js) are preserved (TypeError, DOMException, etc) and [more can be added.](#error-constructors) ### options diff --git a/test.js b/test.js index 6268288..f8c512d 100644 --- a/test.js +++ b/test.js @@ -1,6 +1,7 @@ import {Buffer} from 'node:buffer'; import Stream from 'node:stream'; import test from 'ava'; +import errorConstructors from './error-constructors.js'; import {serializeError, deserializeError, isErrorLike} from './index.js'; function deserializeNonError(t, value) { @@ -184,6 +185,17 @@ test('should deserialize and preserve existing properties', t => { t.true(deserialized.customProperty); }); +for (const [name, CustomError] of errorConstructors) { + test(`should deserialize and preserve the ${name} constructor`, t => { + const deserialized = deserializeError({ + name, + message: 'foo', + }); + t.true(deserialized instanceof CustomError); + t.is(deserialized.message, 'foo'); + }); +} + test('should deserialize plain object', t => { const object = { message: 'error message',