Skip to content

Commit

Permalink
feat: Hydrate CustomError and Result. (#344)
Browse files Browse the repository at this point in the history
* feat: Implement custom JSON serialization for CustomErrors.

* feat: Implement a error hydration function.

* feat: Fix toJSON method on CustomError.

* feat: Implement hydrateResult function.

* feat: Make some changes to README according to feedback.

* chore: Add note about serializing cause and data to readme.

* feat: Allow specification of potential custom errors in hydration.

* chore: Remove hydration of CustomError.cause.

* feat: Add error case to result hydration.

* feat: Add optional hydration of error in hydrateResult.

* feat: Omit data or cause when serializing CustomError if they are not serializable.
  • Loading branch information
Hannes Leutloff authored Feb 22, 2022
1 parent 0549e6b commit 3f4343c
Show file tree
Hide file tree
Showing 8 changed files with 395 additions and 0 deletions.
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,27 @@ try {
const error = new TokenMalformed({ data: { foo: 'bar' }});
```

#### Serializing, Deserializing and Hydrating errors

Sometimes you need to serialize and deserialize your errors. Afterwards they are missing their prototype-chain and `Error`-related functionality. To restore those, you can hydrate a raw object to a `CustomError`-instance:

```typescript
import { defekt, hydrateCustomError } from 'defekt';

class TokenMalformed extends defekt({ code: 'TokenMalformed' }) {}

const serializedTokenMalformedError = JSON.stringify(new TokenMalformed());

const rawEx = JSON.parse(serializedTokenMalformedError);

const ex = hydrateCustomError({ rawEx, potentialErrorConstructors: [ TokenMalformed ] }).unwrapOrThrow();
```

Note that the hydrated error is wrapped in a `Result`. If the raw error can not be hydrated using one of the given potential error constructors, an error-`Result` will be returned, which tells you, why the hydration was unsuccessful.
Also note that the `cause` of a `CustomError` is currently not hydrated, but left as-is.

Usually, JavaScript `Error`s are not well suited for JSON-serialization. To improve this, the `CustomError` class implements [`toJSON()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#tojson_behavior), which defines custom JSON-serialization behavior. If you want to be able to serialize your `cause` and `data` as well, they need to be either plain objects or also implement the `toJSON` method.

### Using custom error type-guards

Custom errors can be type-guarded using `isCustomError`. With only one parameter it specifies an error's type to `CustomError`:
Expand Down Expand Up @@ -286,6 +307,30 @@ if (isResult(someValue)) {
}
```

### Hydrating a `Result`

Like for errors, there is a function to hydrate a `Result` from raw data in case you need to serialize and deserialize a `Result`.

```typescript
import { defekt, hydrateResult } from 'defekt';

const rawResult = JSON.parse(resultFromSomewhere);

const hydrationResult = hydrateResult({ rawResult });

if (hydrationResult.hasError()) {
// The hydration has failed.
} else {
const result = hydrationResult.value;

if (result.hasError()) {
// Continue with your normal error handling.
}
}
```

You can also optionally let `hydrateResult` hydrate the contained error by passing `potentialErrorConstructors`. This works identically to `hydrateResult`.

## Running quality assurance

To run quality assurance for this module use [roboter](https://www.npmjs.com/package/roboter):
Expand Down
25 changes: 25 additions & 0 deletions lib/CustomError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ class CustomError<TErrorName extends string = string> extends Error {
this.cause = cause;
this.data = data;
}

// eslint-disable-next-line @typescript-eslint/naming-convention
public toJSON (): any {
const serializableObject: any = {
name: this.name,
message: this.message,
code: this.code,
stack: this.stack
};

try {
JSON.stringify(this.data);
serializableObject.data = this.data;
} catch {
// If data is not serializable, we want to omit it from the returned object.
}
try {
JSON.stringify(this.cause);
serializableObject.cause = this.cause;
} catch {
// If data is not serializable, we want to omit it from the returned object.
}

return serializableObject;
}
}

export {
Expand Down
55 changes: 55 additions & 0 deletions lib/hydrateCustomError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { CustomError } from './CustomError';
import { CustomErrorConstructor } from './CustomErrorConstructor';
import { defekt } from './defekt';
import { error, Result, value } from './Result';

class HydratingErrorFailed extends defekt({ code: 'HydratingErrorFailed' }) {}

const hydrateCustomError = function<TPotentialCustomErrorNames extends string = string> ({
rawEx, potentialErrorConstructors
}: {
rawEx: any;
potentialErrorConstructors: CustomErrorConstructor<TPotentialCustomErrorNames>[];
}): Result<CustomError<TPotentialCustomErrorNames>, HydratingErrorFailed> {
if (typeof rawEx !== 'object' || rawEx === null) {
return error(new HydratingErrorFailed('The given error is not an object.'));
}

if (!('name' in rawEx)) {
return error(new HydratingErrorFailed('The given error is missing a name.'));
}
if (!('code' in rawEx)) {
return error(new HydratingErrorFailed('The given error is missing a code.'));
}
if (!('message' in rawEx)) {
return error(new HydratingErrorFailed('The given error is missing a message.'));
}

const ActualErrorConstructor = potentialErrorConstructors.find(
(errorContructor): boolean => errorContructor.code === rawEx.code
);

if (ActualErrorConstructor === undefined) {
return error(new HydratingErrorFailed({
message: 'Could not find an appropriate ErrorConstructor to hydrate the given error.',
data: { code: rawEx.code }
}));
}

const stack = rawEx?.stack;
const data = rawEx?.data;
const cause = rawEx?.cause;

const hydratedError = new ActualErrorConstructor({ message: rawEx.message, cause, data });

hydratedError.name = rawEx.name;
hydratedError.stack = stack;

return value(hydratedError);
};

export {
HydratingErrorFailed,

hydrateCustomError
};
37 changes: 37 additions & 0 deletions lib/hydrateResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { CustomError } from './CustomError';
import { CustomErrorConstructor } from './CustomErrorConstructor';
import { defekt } from './defekt';
import { hydrateCustomError } from './hydrateCustomError';
import { error, Result, value } from './Result';

class HydratingResultFailed extends defekt({ code: 'HydratingResultFailed' }) {}

const hydrateResult = function<TValue, TError extends Error, TPotentialCustomErrorNames extends string = string> ({ rawResult, potentialErrorConstructors }: {
rawResult: { value: TValue } | { error: TError };
potentialErrorConstructors?: CustomErrorConstructor<TPotentialCustomErrorNames>[];
}): Result<Result<TValue, TError | CustomError<TPotentialCustomErrorNames>>, HydratingResultFailed> {
if ('value' in rawResult) {
return value(value(rawResult.value) as Result<TValue, any>);
}
if ('error' in rawResult) {
let processedError: Error = rawResult.error;

if (potentialErrorConstructors) {
const hydrateErrorResult = hydrateCustomError({ rawEx: rawResult.error, potentialErrorConstructors });

if (hydrateErrorResult.hasError()) {
return error(new HydratingResultFailed({ cause: hydrateErrorResult.error }));
}

processedError = hydrateErrorResult.value;
}

return value(error(processedError) as Result<TValue, any>);
}

return error(new HydratingResultFailed());
};

export {
hydrateResult
};
5 changes: 5 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { CustomError } from './CustomError';
import { CustomErrorConstructor } from './CustomErrorConstructor';
import { defekt } from './defekt';
import { hydrateResult } from './hydrateResult';
import { isCustomError } from './isCustomError';
import { isError } from './isError';
import { isResult } from './isResult';
import { error, Result, ResultDoesNotContainError, value } from './Result';
import { hydrateCustomError, HydratingErrorFailed } from './hydrateCustomError';

export {
CustomError,
defekt,
error,
hydrateCustomError,
HydratingErrorFailed,
hydrateResult,
isCustomError,
isError,
isResult,
Expand Down
50 changes: 50 additions & 0 deletions test/unit/defektTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,54 @@ suite('defekt', (): void => {
}
}
});

test('creates a custom error that serializes to JSON in a meaningful way.', async (): Promise<void> => {
class TokenInvalid extends defekt({ code: 'TokenInvalid' }) {}

const cause = new TokenInvalid();
const ex = new TokenInvalid({ message: 'Foo', data: { foo: 'bar' }, cause });

assert.that(JSON.stringify(ex)).is.equalTo(JSON.stringify({
name: 'TokenInvalid',
message: formatErrorMessage({ code: 'TokenInvalid' }),
code: 'TokenInvalid',
stack: ex.stack,
data: { foo: 'bar' },
cause: JSON.stringify(cause)
}));
});

test('creates a custom error that serializes to JSON and omits cause if it is not serializable.', async (): Promise<void> => {
class TokenInvalid extends defekt({ code: 'TokenInvalid' }) {}

const objectA: any = {};
const objectB: any = { objectA };

objectA.objectB = objectB;
const ex = new TokenInvalid({ message: 'Foo', cause: objectA });

assert.that(JSON.stringify(ex)).is.equalTo(JSON.stringify({
name: 'TokenInvalid',
message: formatErrorMessage({ code: 'TokenInvalid' }),
code: 'TokenInvalid',
stack: ex.stack
}));
});

test('creates a custom error that serializes to JSON and omits data if it is not serializable.', async (): Promise<void> => {
class TokenInvalid extends defekt({ code: 'TokenInvalid' }) {}

const objectA: any = {};
const objectB: any = { objectA };

objectA.objectB = objectB;
const ex = new TokenInvalid({ message: 'Foo', data: objectA });

assert.that(JSON.stringify(ex)).is.equalTo(JSON.stringify({
name: 'TokenInvalid',
message: formatErrorMessage({ code: 'TokenInvalid' }),
code: 'TokenInvalid',
stack: ex.stack
}));
});
});
100 changes: 100 additions & 0 deletions test/unit/hydrateCustomErrorTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { assert } from 'assertthat';
import { defekt, hydrateCustomError, isCustomError } from '../../lib';

suite('hydrateCustomError', (): void => {
test('creates an error instance from raw data.', async (): Promise<void> => {
class TokenInvalid extends defekt({ code: 'TokenInvalid' }) {}
class TokenExpired extends defekt({ code: 'TokenExpired' }) {}

const rawEx = {
name: 'TokenInvalid',
code: 'TokenInvalid',
message: 'Foo',
data: { foo: 'bar' },
cause: {
name: 'TokenExpired',
code: 'TokenExpired',
message: 'Token expired.'
}
};

const ex = hydrateCustomError({
rawEx,
potentialErrorConstructors: [ TokenInvalid, TokenExpired ]
}).unwrapOrThrow();

assert.that(isCustomError(ex, TokenInvalid)).is.true();
assert.that(ex.cause).is.equalTo(rawEx.cause);
});

test('fails to hydrate an error that is missing a name.', async (): Promise<void> => {
const rawEx = {
code: 'TokenInvalid',
message: 'Foo'
};

const hydrateResult = hydrateCustomError({
rawEx,
potentialErrorConstructors: []
});

assert.that(hydrateResult).is.anErrorWithMessage('The given error is missing a name.');
});

test('fails to hydrate an error that is missing a code.', async (): Promise<void> => {
const rawEx = {
name: 'TokenInvalid',
message: 'Foo'
};

const hydrateResult = hydrateCustomError({
rawEx,
potentialErrorConstructors: []
});

assert.that(hydrateResult).is.anErrorWithMessage('The given error is missing a code.');
});

test('fails to hydrate an error that is missing a message.', async (): Promise<void> => {
const rawEx = {
name: 'TokenInvalid',
code: 'TokenInvalid'
};

const hydrateResult = hydrateCustomError({
rawEx,
potentialErrorConstructors: []
});

assert.that(hydrateResult).is.anErrorWithMessage('The given error is missing a message.');
});

test('fails to hydrate an error for which no appropriate error constructer is given.', async (): Promise<void> => {
class TokenExpired extends defekt({ code: 'TokenExpired' }) {}

const rawEx = {
name: 'TokenInvalid',
code: 'TokenInvalid',
message: 'Foo'
};

const hydrateResult = hydrateCustomError({
rawEx,
potentialErrorConstructors: [ TokenExpired ]
});

assert.that(hydrateResult).is.anErrorWithMessage('Could not find an appropriate ErrorConstructor to hydrate the given error.');
});

test('can hydrate serialized and deserialized errors.', async (): Promise<void> => {
class TokenInvalid extends defekt({ code: 'TokenInvalid' }) {}

const ex = new TokenInvalid();
const hydratedEx = hydrateCustomError({
rawEx: JSON.parse(JSON.stringify(ex)),
potentialErrorConstructors: [ TokenInvalid ]
}).unwrapOrThrow();

assert.that(isCustomError(hydratedEx, TokenInvalid)).is.true();
});
});
Loading

0 comments on commit 3f4343c

Please sign in to comment.