Skip to content

Commit 9897b89

Browse files
committed
Integrate JSON serialization and deserialization
1 parent ffe1ea6 commit 9897b89

File tree

3 files changed

+239
-2
lines changed

3 files changed

+239
-2
lines changed

src/AbstractError.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { POJO } from './types';
1+
import type { POJO, Class } from './types';
22
import { performance } from 'perf_hooks';
33
import { CustomError } from 'ts-custom-error';
44

@@ -12,6 +12,31 @@ class AbstractError<T> extends CustomError {
1212
*/
1313
public static description: string = '';
1414

15+
public static fromJSON<T extends Class<any>>(
16+
this: T,
17+
json: any,
18+
): InstanceType<T> {
19+
if (
20+
typeof json !== 'object' ||
21+
json.type !== this.name ||
22+
typeof json.data !== 'object' ||
23+
typeof json.data.message !== 'string' ||
24+
isNaN(Date.parse(json.data.timestamp)) ||
25+
typeof json.data.data !== 'object' ||
26+
!('cause' in json.data) ||
27+
('stack' in json.data && typeof json.data.stack !== 'string')
28+
) {
29+
throw new TypeError(`Cannot decode JSON to ${this.name}`);
30+
}
31+
const e = new this(json.data.message, {
32+
timestamp: new Date(json.data.timestamp),
33+
data: json.data.data,
34+
cause: json.data.cause,
35+
});
36+
e.stack = json.data.stack;
37+
return e;
38+
}
39+
1540
/**
1641
* Arbitrary data
1742
*/
@@ -48,6 +73,19 @@ class AbstractError<T> extends CustomError {
4873
public get description(): string {
4974
return this.constructor['description'];
5075
}
76+
77+
public toJSON(): any {
78+
return {
79+
type: this.constructor.name,
80+
data: {
81+
message: this.message,
82+
timestamp: this.timestamp,
83+
data: this.data,
84+
cause: this.cause,
85+
stack: this.stack,
86+
},
87+
};
88+
}
5189
}
5290

5391
export default AbstractError;

src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@
33
*/
44
type POJO = { [key: string]: any };
55

6-
export type { POJO };
6+
type Class<T> = new (...args: any[]) => T;
7+
8+
export type { POJO, Class };

tests/index.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Class } from '@/types';
12
import { AbstractError } from '@';
23

34
describe('index', () => {
@@ -55,4 +56,200 @@ describe('index', () => {
5556
expect(e.cause).toBe(eOriginal);
5657
}
5758
});
59+
test('inheritance', () => {
60+
// Propagate the cause, allow cause to be generic
61+
// and the cause is determined by the instantiator
62+
class ErrorPropagate<T> extends AbstractError<T> {}
63+
// Fix the cause, so that `SyntaxError` is always the cause
64+
class ErrorFixed extends AbstractError<SyntaxError> {}
65+
// Default the cause, but allow instantiator to override
66+
class ErrorDefault<T = TypeError> extends AbstractError<T> {}
67+
const eP = new ErrorPropagate<string>(undefined, { cause: 'string' });
68+
const eF = new ErrorFixed(undefined, { cause: new SyntaxError() });
69+
const eD = new ErrorDefault<number>(undefined, { cause: 123 });
70+
expect(eP.cause).toBe('string');
71+
expect(eF.cause).toBeInstanceOf(SyntaxError);
72+
expect(eD.cause).toBe(123);
73+
});
74+
test('JSON encoding/decoding', () => {
75+
const e = new AbstractError();
76+
expect(AbstractError.fromJSON(e.toJSON())).toBeInstanceOf(AbstractError);
77+
expect(AbstractError.fromJSON(e.toJSON()).toJSON()).toStrictEqual(
78+
e.toJSON(),
79+
);
80+
const eJSON = {
81+
type: 'AbstractError',
82+
data: {
83+
message: 'some message',
84+
timestamp: '2022-05-07T09:16:06.632Z',
85+
data: {},
86+
cause: undefined,
87+
},
88+
};
89+
const e2 = AbstractError.fromJSON(eJSON);
90+
expect(e2).toBeInstanceOf(AbstractError);
91+
expect(e2.message).toBe(eJSON.data.message);
92+
expect(e2.timestamp).toStrictEqual(new Date(eJSON.data.timestamp));
93+
expect(e2.data).toStrictEqual(eJSON.data.data);
94+
expect(e2.cause).toStrictEqual(eJSON.data.cause);
95+
});
96+
describe('JSON serialiation and deserialisation', () => {
97+
// Demonstrates an extended error with its own encoding and decoding
98+
class SpecificError<T> extends AbstractError<T> {
99+
public static fromJSON<T extends Class<any>>(
100+
this: T,
101+
json: any,
102+
): InstanceType<T> {
103+
if (
104+
typeof json !== 'object' ||
105+
json.type !== this.name ||
106+
typeof json.data !== 'object' ||
107+
typeof json.data.message !== 'string' ||
108+
typeof json.data.num !== 'number' ||
109+
('stack' in json.data && typeof json.data.stack !== 'string')
110+
) {
111+
throw new TypeError(`Cannot decode JSON to ${this.name}`);
112+
}
113+
const e = new this(json.data.num, json.data.message);
114+
e.stack = json.data.stack;
115+
return e;
116+
}
117+
public num: number;
118+
public constructor(num: number, message: string) {
119+
super(message);
120+
this.num = num;
121+
}
122+
public toJSON() {
123+
const obj = super.toJSON();
124+
obj.data.num = this.num;
125+
return obj;
126+
}
127+
}
128+
class UnknownError<T> extends AbstractError<T> {}
129+
// AbstractError classes, these should be part of our application stack
130+
const customErrors = {
131+
AbstractError,
132+
SpecificError,
133+
UnknownError,
134+
};
135+
// Standard JS errors, these do not have fromJSON routines
136+
const standardErrors = {
137+
Error,
138+
TypeError,
139+
SyntaxError,
140+
ReferenceError,
141+
EvalError,
142+
RangeError,
143+
URIError,
144+
};
145+
function replacer(key: string, value: any): any {
146+
if (value instanceof Error) {
147+
return {
148+
type: value.constructor.name,
149+
data: {
150+
message: value.message,
151+
stack: value.stack,
152+
},
153+
};
154+
} else {
155+
return value;
156+
}
157+
}
158+
// Assume that the purpose of the reviver is to deserialise JSON string
159+
// back into exceptions
160+
// The reviver has 3 choices when encountering an unknown value
161+
// 1. throw an "parse" exception
162+
// 2. return the value as it is
163+
// 3. return as special "unknown" exception that contains the unknown value as data
164+
// Choice 1. results in strict deserialisation procedure (no forwards-compatibility)
165+
// Choice 2. results in ambiguous parsed result
166+
// Choice 3. is the best option as it ensures a typed-result and debuggability of ambiguous data
167+
function reviver(key: string, value: any): any {
168+
if (
169+
typeof value === 'object' &&
170+
typeof value.type === 'string' &&
171+
typeof value.data === 'object'
172+
) {
173+
try {
174+
let eClass = customErrors[value.type];
175+
if (eClass != null) return eClass.fromJSON(value);
176+
eClass = standardErrors[value.type];
177+
if (eClass != null) {
178+
if (
179+
typeof value.data.message !== 'string' ||
180+
('stack' in value.data && typeof value.data.stack !== 'string')
181+
) {
182+
throw new TypeError(`Cannot decode JSON to ${value.type}`);
183+
}
184+
const e = new eClass(value.data.message);
185+
e.stack = value.data.stack;
186+
return e;
187+
}
188+
} catch (e) {
189+
// If `TypeError` which represents decoding failure
190+
// then return value as-is
191+
// Any other exception is a bug
192+
if (!(e instanceof TypeError)) {
193+
throw e;
194+
}
195+
}
196+
// Other values are returned as-is
197+
return value;
198+
} else if (key === '') {
199+
// Root key will be ''
200+
// Reaching here means the root JSON value is not a valid exception
201+
// Therefore UnknownError is only ever returned at the top-level
202+
return new UnknownError('Unknown error JSON', {
203+
data: {
204+
json: value,
205+
},
206+
});
207+
} else {
208+
// Other values will be returned as-is
209+
return value;
210+
}
211+
}
212+
test('abstract on specific', () => {
213+
const e = new AbstractError('msg1', {
214+
cause: new SpecificError(123, 'msg2'),
215+
});
216+
const eJSONString = JSON.stringify(e, replacer);
217+
const e_ = JSON.parse(eJSONString, reviver);
218+
expect(e_).toBeInstanceOf(AbstractError);
219+
expect(e_.message).toBe(e.message);
220+
expect(e_.cause).toBeInstanceOf(SpecificError);
221+
expect(e_.cause.message).toBe(e.cause.message);
222+
});
223+
test('abstract on abstract on range', () => {
224+
const e = new AbstractError('msg1', {
225+
cause: new AbstractError('msg2', {
226+
cause: new RangeError('msg3'),
227+
}),
228+
});
229+
const eJSONString = JSON.stringify(e, replacer);
230+
const e_ = JSON.parse(eJSONString, reviver);
231+
expect(e_).toBeInstanceOf(AbstractError);
232+
expect(e_.message).toBe(e.message);
233+
expect(e_.cause).toBeInstanceOf(AbstractError);
234+
expect(e_.cause.message).toBe(e.cause.message);
235+
expect(e_.cause.cause).toBeInstanceOf(RangeError);
236+
expect(e_.cause.cause.message).toBe(e.cause.cause.message);
237+
});
238+
test('abstract on something random', () => {
239+
const e = new AbstractError('msg1', {
240+
cause: 'something random',
241+
});
242+
const eJSONString = JSON.stringify(e, replacer);
243+
const e_ = JSON.parse(eJSONString, reviver);
244+
expect(e_).toBeInstanceOf(AbstractError);
245+
expect(e_.message).toBe(e.message);
246+
expect(e_.cause).toBe('something random');
247+
});
248+
test('unknown at root', () => {
249+
const e = '123';
250+
const eJSONString = JSON.stringify(e, replacer);
251+
const e_ = JSON.parse(eJSONString, reviver);
252+
expect(e_).toBeInstanceOf(UnknownError);
253+
});
254+
});
58255
});

0 commit comments

Comments
 (0)