Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ export type Options = {
@default true
*/
readonly useToJSON?: boolean;

/**
Wrap the deserialized error as the `cause` of a new error, capturing the current stack while preserving the original deserialized error.

This is useful when you want to throw a deserialized error but keep a stack trace pointing to the current call site.

Only applies to `deserializeError`.

@default false
*/
readonly asCause?: boolean;
};

/**
Expand Down Expand Up @@ -121,6 +132,7 @@ Deserialize a plain object or any value into an `Error` object.
- Enumerable properties are kept enumerable (all properties besides the non-enumerable ones).
- Circular references are handled.
- Native error constructors are preserved (TypeError, DOMException, etc) and more can be added.
- The deserialized error can be wrapped as the `cause` of a new error to capture the current stack.

@example
```
Expand Down
36 changes: 32 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ const newError = name => {
: new ErrorConstructor();
};

const wrapAsCause = (error, stackStartFunction) => {
const wrappedError = newError(error.name);

for (const property of ['name', 'message', 'cause', 'errors']) {
const value = property === 'cause' ? error : error[property];
if (value === undefined || value === null) {
continue;
}

Object.defineProperty(wrappedError, property, {
value,
enumerable: false,
configurable: true,
writable: true,
});
}

Error.captureStackTrace?.(wrappedError, stackStartFunction);

return wrappedError;
};

const destroyCircular = ({
from,
seen,
Expand Down Expand Up @@ -205,24 +227,30 @@ export function serializeError(value, options = {}) {
}

export function deserializeError(value, options = {}) {
const {maxDepth = Number.POSITIVE_INFINITY} = options;
const {
maxDepth = Number.POSITIVE_INFINITY,
asCause = false,
} = options;

if (value instanceof Error) {
return value;
return asCause ? wrapAsCause(value, deserializeError) : value;
}

let deserializedError;
if (isMinimumViableSerializedError(value)) {
return destroyCircular({
deserializedError = destroyCircular({
from: value,
seen: new Set(),
to: newError(value.name),
maxDepth,
depth: 0,
serialize: false,
});
} else {
deserializedError = new NonError(value);
}

return new NonError(value);
return asCause ? wrapAsCause(deserializedError, deserializeError) : deserializedError;
}

export function isErrorLike(value) {
Expand Down
6 changes: 5 additions & 1 deletion index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ expectTypeOf(serializeError(null)).toEqualTypeOf<ErrorObject>();
expectTypeOf(serializeError(() => {})).toEqualTypeOf<ErrorObject>();
expectTypeOf(serializeError(error as unknown)).toEqualTypeOf<ErrorObject>();
expectTypeOf(serializeError(error)).toEqualTypeOf<ErrorObject>();
expectTypeOf({maxDepth: 1}).toMatchTypeOf<Options>();
expectTypeOf({maxDepth: 1}).toExtend<Options>();
expectTypeOf({asCause: true}).toExtend<Options>();

expectTypeOf(deserializeError({
message: 'error message',
stack: 'at <anonymous>:1:13',
name: 'name',
code: 'code',
})).toEqualTypeOf<Error>();
expectTypeOf(deserializeError({
message: 'error message',
}, {asCause: true})).toEqualTypeOf<Error>();

addKnownErrorConstructor(Error);

Expand Down
18 changes: 18 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ Deserialize a plain object or any value into an `Error` object.
- 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)
- The deserialized error can be wrapped as the `cause` of a new error to capture the current stack.

### value

Expand Down Expand Up @@ -181,6 +182,23 @@ Default: `true`

Indicate whether to use a `.toJSON()` method if encountered in the object. This is useful when a custom error implements [its own serialization logic via `.toJSON()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#tojson_behavior) but you prefer to not use it.

#### asCause

Type: `boolean`\
Default: `false`

Wrap the deserialized error as the `cause` of a new error, capturing the current stack. This is useful when you want to throw a deserialized error but keep a stack trace pointing to the current call site instead of the original serialized one.

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

const error = deserializeError(serializedError, {asCause: true});

// `error.stack` points here, the original is preserved as the cause
console.log(error.cause);
//=> [Error: Original error]
```

### isErrorLike(value)

Predicate to determine whether a value looks like an error, even if it's not an instance of `Error`. It must have at least the `name`, `message`, and `stack` properties.
Expand Down
73 changes: 73 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,79 @@ test('should deserialize plain object', t => {
t.is(deserialized.code, 'code');
});

test('should wrap deserialized errors as cause with a current stack', t => {
const deserialized = deserializeError({
name: 'TypeError',
message: 'error message',
stack: 'serialized stack',
code: 'code',
}, {asCause: true});

t.true(deserialized instanceof TypeError);
t.is(deserialized.name, 'TypeError');
t.is(deserialized.message, 'error message');
t.is(deserialized.code, undefined);
t.true(deserialized.cause instanceof TypeError);
t.is(deserialized.cause.message, 'error message');
t.is(deserialized.cause.stack, 'serialized stack');
t.is(deserialized.cause.code, 'code');
t.false(Object.keys(deserialized).includes('cause'));
t.not(deserialized.stack, 'serialized stack');
t.regex(deserialized.stack, /test\.js/);
t.false(deserialized.stack.includes('wrapAsCause'));
});

test('should wrap deserialized errors with custom constructors', t => {
class WrappedCustomError extends Error {
name = 'WrappedCustomError';
}

addKnownErrorConstructor(WrappedCustomError);

const deserialized = deserializeError({
name: 'WrappedCustomError',
message: 'custom error message',
stack: 'serialized custom stack',
}, {asCause: true});

t.true(deserialized instanceof WrappedCustomError);
t.is(deserialized.message, 'custom error message');
t.true(deserialized.cause instanceof WrappedCustomError);
t.is(deserialized.cause.message, 'custom error message');
t.is(deserialized.cause.stack, 'serialized custom stack');
});

test('should wrap existing Error instances when requested', t => {
const error = new RangeError('existing error');
const deserialized = deserializeError(error, {asCause: true});

t.true(deserialized instanceof RangeError);
t.is(deserialized.message, 'existing error');
t.is(deserialized.cause, error);
t.not(deserialized, error);
});

test('should preserve AggregateError errors when wrapping as cause', t => {
const deserialized = deserializeError({
name: 'AggregateError',
message: 'multiple failures',
stack: 'serialized stack',
errors: [
{name: 'Error', message: 'inner one', stack: 'inner one stack'},
{name: 'TypeError', message: 'inner two', stack: 'inner two stack'},
],
}, {asCause: true});

t.true(deserialized instanceof AggregateError);
t.is(deserialized.errors.length, 2);
t.is(deserialized.errors[0].message, 'inner one');
t.true(deserialized.errors[1] instanceof TypeError);
t.is(deserialized.errors[1].message, 'inner two');
t.true(deserialized.cause instanceof AggregateError);
t.is(deserialized.cause.errors.length, 2);
t.false(Object.keys(deserialized).includes('errors'));
});

test('should preserve buffers when deserializing', t => {
const buffer = Buffer.from([1, 2, 3]);
const deserialized = deserializeError({
Expand Down