Repository: sindresorhus/serialize-error (js)

Issue:
Error subclasses are lost when deserializing
```js
const {serializeError, deserializeError} = require('serialize-error');

class MyError extends Error {
    constructor(message) {
        super(message);
        this.name = 'MyError'
    }
}

const original = new MyError('This is my error');
const serialized = serializeError(original);
const deserialized = deserializeError(serialized);
```

`original` will be a `MyError` but `deserialized` will be a plain `Error`

Live example: https://runkit.com/embed/f46ewcgxyxqg

I think `deserializeError` should support custom error types, if passed as an argument (so they are known and explicit):


```js
const deserialized = deserializeError(serialized, {constructors: [MyError, BusinessError, ErrorError]});
```

Native errors also should be automatically supported, like `TypeError`

Pull request description:
Preserve error constructor
<details>
<summary>Previous notes about native-only support</summary>

Partially fix for #48. 

Includes and is blocked by #69 

Custom constructors will need extra logic, because what happens when `CustomError`’s signature **requires** a second parameter? 

- Deserialization should catch that and use a regular Error, and/or
- The user should pass `.set(MyError, object => new MyError(object.message, object.otherProp))`

But this might require further bikeshedding, so I'm leaving it out of this PR

</details>

Fixes #48

Patch:
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<string, ErrorConstructor>;
+
+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
 


Test patch:
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',