# serialize-error: turning an Error into something JSON.stringify will keep

> serialize-error is a small package that converts an Error into a plain object and back again, preserving the error type, custom properties, stack and cause, and handling circular references. It fits a Node service that sends errors across a process boundary or into a log pipeline, and it is deliberately tiny with two runtime dependencies.

**sindresorhus/serialize-error** — Serialize/deserialize an error into a plain object

- Repository: https://github.com/sindresorhus/serialize-error
- Stars: 607 · Forks: 68
- Language: JavaScript
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/sindresorhus-serialize-error

## The problem is that Error properties are not enumerable

The README states the purpose in one line, to serialize and deserialize an error into a plain object, and gives the reason it matters: you need it if, for example, you want to JSON.stringify() or process.send() the error.

That reason is the whole design. A plain Error has its message and stack as non-enumerable properties, so JSON.stringify turns an Error into an empty object. Anything that has to leave the process, whether through a child process message, a job queue or a JSON log line, loses the information that makes the error useful.

The package's answer is a round trip rather than a one-way dump. You call serializeError on an Error and get a plain object with the name, message and stack, and you call deserializeError on that object and get an Error back, with the same class.

```js
import {serializeError, deserializeError} from 'serialize-error';

const error = new Error('🦄');

console.log(error);
//=> [Error: 🦄]

const serialized = serializeError(error);

console.log(serialized);
//=> {name: 'Error', message: '🦄', stack: 'Error: 🦄\n    at Object.<anonymous> …'}

const deserialized = deserializeError(serialized);

console.log(deserialized);
//=> [Error: 🦄]
```

Note the shape of the serialised value. It is a plain object with a name, a message and a stack, which means it survives JSON.stringify and arrives on the other side as something you can inspect without a special parser.

The audience is a Node developer who has hit the empty-object problem and wants the error type back, not just a string. The package is MIT licensed with a license file at the root, the last push was on 2026-09-28 with the repository not archived, and the release history shows v13.0.0 in January 2026, v13.0.1 a few days later and v13.0.2 in September 2026.

## Error classes survive the round trip through a constructor table

The part that goes beyond a plain object dump is type preservation. When a serialised error with a known name is encountered, it is deserialised using the corresponding error constructor, and unknown error names are deserialised 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 mechanism is a table of known error constructors, in the file error-constructors.js at the repository root, and it can be extended globally. The README notes that this also works when serialize-error is a sub-dependency that is not used directly, which matters for a package you might only have transitively.

Registering a custom class takes a call to addKnownErrorConstructor, and the README shows both the simple form and the factory form:

```js
import {addKnownErrorConstructor} from 'serialize-error';
import {MyCustomError} from './errors.js'

addKnownErrorConstructor(MyCustomError);
```

The factory form exists for errors that require arguments in their constructor, since the deserialiser has only a name and a message to work with. The example class takes a message and an options object with a code, and the factory is called with an empty message and a code, so the reconstructed error carries the code but not the original message:

```js
addKnownErrorConstructor(CustomError, () => new CustomError('', {code: 'ERR_UNICORN'}));
```

That is a real limitation to be aware of: an error whose constructor needs more than a message does not come back whole, only with the extra fields the factory supplies.

## The API rules: enumerability, wrapping, and the NonError case

The API section of the README lists the behaviour of serializeError precisely, and the details are what you would otherwise have to discover by reading the source.

Custom properties are preserved. Non-enumerable properties are kept non-enumerable, and the ones named are name, message and stack. Enumerable properties are kept enumerable, meaning everything besides the non-enumerable ones. So the round trip does not flatten your error's shape.

Primitive values, including null, undefined, strings and numbers, and functions are wrapped in a NonError error and serialised. That is what makes deserializeError total: it always returns an Error, never a raw value, which is a good property for a function that sits in a logging path.

Buffer properties are replaced with the string [object Buffer]. Circular references are handled. And if the input object has a .toJSON() method, then it is called instead of serialising the object's properties, with the note that it is up to the .toJSON() implementation to handle circular references and enumerability. The README illustrates both halves of that, first with a class that sets a date so the output shows an ISO string, then with an object that has a toJSON method returning a plain value, so the output shows the custom value rather than the object.

deserializeError has the mirror rules. Error objects are passed through. Objects that have at least a message property are interpreted as errors, and all other values are wrapped in a NonError error. Custom properties are preserved, non-enumerable properties stay non-enumerable including name, message, stack and cause, and circular references are handled. Native error constructors are preserved, with TypeError and DOMException named as examples.

So the contract is that an error goes in and an error comes out, with its class intact, and the type of the value you passed is preserved too.

## maxDepth and useToJSON are the two knobs you have

Two options exist, and both exist because serialising an arbitrary object graph can go wrong in ways you do not control.

The first is maxDepth, a number defaulting to Number.POSITIVE_INFINITY, described as the maximum depth of properties to preserve when serializing and deserializing. The example builds an error with a nested object one, two, three, and shows that a depth of 1 keeps only the top level, so the output is the name, the message and one with its value, while a depth of 2 keeps one and two:

```js
import {serializeError} from 'serialize-error';

const error = new Error('🦄');
error.one = {two: {three: {}}};

console.log(serializeError(error, {maxDepth: 1}));
//=> {name: 'Error', message: '🦄', one: {}}

console.log(serializeError(error, {maxDepth: 2}));
//=> {name: 'Error', message: '🦄', one: { two: {}}}
```

The second is useToJSON, a boolean defaulting to true, which indicates whether to use a .toJSON() method if encountered in the object. The README gives the reason, that this is useful when a custom error implements its own serialization logic via .toJSON() but you prefer to not use it.

Together these two settings cover the two ways a serialised error can surprise you. maxDepth bounds an object graph that goes deeper than the log line can hold. useToJSON stops an error class from silently deciding how it is represented.

There is a third export worth knowing about, isErrorLike, a predicate to determine whether a value looks like an error even if it is not an instance of Error. It must have at least the name, message and stack properties. The examples show a plain object shaped like a DOMException returning true, a real Error returning true, a serialised Error returning true, and an object with a name, a stack and an ingredients property but no message returning false.

## A small package with a small surface, and a strict toolchain

The package.json tells you what you are actually depending on, and the answer is almost nothing.

The two runtime dependencies are non-error at 0.1.0, which is the NonError wrapper you saw referenced in the API section, and type-fest at 5.4.1, which supplies the TypeScript types. Everything else is a development dependency: ava for the tests, expect-type and tsd for the type tests, and xo for the linting. The test script runs all three in sequence, xo and then ava and then tsd, which tells you the package treats its type definitions as a tested artifact rather than an afterthought. There is an index.test-d.ts file at the repository root for the tsd part, and a separate error-constructors.d.ts.

The package is ESM only, with type set to module, and the exports map points types at ./index.d.ts and default at ./index.js. sideEffects is false, so a bundler can drop the import entirely if you never call it. The engines field requires Node 20 or newer, and the files array ships exactly four things: index.js, index.d.ts, error-constructors.js and error-constructors.d.ts.

The repository root matches that: index.js, index.d.ts, error-constructors.js, error-constructors.d.ts, a test.js, an index.test-d.ts, a readme.md, an .npmrc, a package.json and the usual editor and CI configuration. The entire implementation is a few files, which is the point. A package that sits in every error path of a request handler should be small enough to read.

The project is funded through GitHub Sponsors, and the author is Sindre Sorhus, whose other packages follow the same shape of a narrow utility with strict typing and an ESM-only distribution.

## Where serialize-error is the wrong tool

The first wrong-tool case is an error class whose constructor needs real arguments. The factory form of addKnownErrorConstructor works, but it means the reconstructed error is not the error that was thrown. If your code branches on a field like a status code that came from the original throw site, you are reconstructing it by hand, and any divergence is your problem rather than the library's.

The second is a codebase whose logger already handles errors well. Many structured loggers call toJSON or use a custom serializer for Error, and if yours does, this package duplicates that work and adds a dependency to your error path. The README is aimed at the case where you want to JSON.stringify() or process.send() an error, not at the case where a logger is already in place.

The third is a need to serialise arbitrary objects. The API is scoped to errors, and the NonError wrapping is a fallback for values that are not errors, not a general object serialiser. If you want a structured clone with type fidelity for arbitrary data, this is the wrong shape and a general purpose serialiser is the right one.

The fourth is a browser bundle that must stay tiny. Two runtime dependencies is small, but the package is ESM only and requires Node 20 or newer for the engines field, so in a browser build you are relying on a bundler and on the type-fest dependency being tree-shaken away. That works, but it is a Node-first package.

The fifth is an expectation that the cause chain survives intact. The deserialise rules list cause among the non-enumerable properties that are kept non-enumerable, but the README does not describe reconstructing nested causes, and the property list for serialisation is name, message and stack. If you rely on error.cause to carry the originating failure, test that specific path rather than assuming the whole chain round trips.

## Conclusion

serialize-error fits a Node service that needs to move errors across process boundaries, into structured logs or into a queue payload without losing the error class or the stack. It is the wrong choice if you need to serialise arbitrary objects that are not errors, and it is unnecessary if your logger already stringifies errors well. Before adopting it, check how your error types set name, since the known-constructor table keys on that property, and decide whether you want the checked-decode behaviour of the default configuration before you deserialize anything that came from outside your process.

## FAQ

### What does the serialize-error package do?

It serializes an Error object into a plain object and deserializes a plain object back into an Error, keeping the error type, custom properties, stack and cause, and handling circular references.

### How do I keep a custom error class when deserializing with serialize-error?

Register it with addKnownErrorConstructor, and if its constructor requires arguments pass a factory function, since the deserializer only has the name and message to work with.

### What are the options for the serialize-error API?

Both serializeError and deserializeError take an options object with maxDepth, a number defaulting to Number.POSITIVE_INFINITY that bounds how deep properties are preserved, and useToJSON, a boolean defaulting to true that controls whether a toJSON method on the object is used.

### How do I install serialize-error?

Run npm install serialize-error, and note that the package is ESM only and its engines field requires Node 20 or newer.

## Sources

- [Issues](https://github.com/sindresorhus/serialize-error/issues)
- [License: MIT](https://github.com/sindresorhus/serialize-error/blob/main/LICENSE)
- [README](https://github.com/sindresorhus/serialize-error/blob/main/README.md)
- [Releases](https://github.com/sindresorhus/serialize-error/releases)
- [sindresorhus/serialize-error on GitHub](https://github.com/sindresorhus/serialize-error)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/sindresorhus-serialize-error
