@devp0nt/error0 1.0.0-next.5 → 1.0.0-next.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/cjs/index.cjs +614 -0
  2. package/dist/cjs/index.cjs.map +1 -0
  3. package/dist/cjs/index.d.cts +345 -414
  4. package/dist/cjs/plugins/cause.cjs +63 -0
  5. package/dist/cjs/plugins/cause.cjs.map +1 -0
  6. package/dist/cjs/plugins/cause.d.cts +15 -0
  7. package/dist/cjs/plugins/code.cjs +46 -0
  8. package/dist/cjs/plugins/code.cjs.map +1 -0
  9. package/dist/cjs/plugins/code.d.cts +8 -0
  10. package/dist/cjs/plugins/expected.cjs +67 -0
  11. package/dist/cjs/plugins/expected.cjs.map +1 -0
  12. package/dist/cjs/plugins/expected.d.cts +37 -0
  13. package/dist/cjs/plugins/message-merge.cjs +39 -0
  14. package/dist/cjs/plugins/message-merge.cjs.map +1 -0
  15. package/dist/cjs/plugins/message-merge.d.cts +8 -0
  16. package/dist/cjs/plugins/meta.cjs +78 -0
  17. package/dist/cjs/plugins/meta.cjs.map +1 -0
  18. package/dist/cjs/plugins/meta.d.cts +7 -0
  19. package/dist/cjs/plugins/stack-merge.cjs +42 -0
  20. package/dist/cjs/plugins/stack-merge.cjs.map +1 -0
  21. package/dist/cjs/plugins/stack-merge.d.cts +8 -0
  22. package/dist/cjs/plugins/status.cjs +60 -0
  23. package/dist/cjs/plugins/status.cjs.map +1 -0
  24. package/dist/cjs/plugins/status.d.cts +9 -0
  25. package/dist/cjs/plugins/tags.cjs +73 -0
  26. package/dist/cjs/plugins/tags.cjs.map +1 -0
  27. package/dist/cjs/plugins/tags.d.cts +12 -0
  28. package/dist/esm/index.d.ts +345 -414
  29. package/dist/esm/index.js +530 -341
  30. package/dist/esm/index.js.map +1 -1
  31. package/dist/esm/plugins/cause.d.ts +15 -0
  32. package/dist/esm/plugins/cause.js +39 -0
  33. package/dist/esm/plugins/cause.js.map +1 -0
  34. package/dist/esm/plugins/code.d.ts +8 -0
  35. package/dist/esm/plugins/code.js +22 -0
  36. package/dist/esm/plugins/code.js.map +1 -0
  37. package/dist/esm/plugins/expected.d.ts +37 -0
  38. package/dist/esm/plugins/expected.js +43 -0
  39. package/dist/esm/plugins/expected.js.map +1 -0
  40. package/dist/esm/plugins/message-merge.d.ts +8 -0
  41. package/dist/esm/plugins/message-merge.js +15 -0
  42. package/dist/esm/plugins/message-merge.js.map +1 -0
  43. package/dist/esm/plugins/meta.d.ts +7 -0
  44. package/dist/esm/plugins/meta.js +54 -0
  45. package/dist/esm/plugins/meta.js.map +1 -0
  46. package/dist/esm/plugins/stack-merge.d.ts +8 -0
  47. package/dist/esm/plugins/stack-merge.js +18 -0
  48. package/dist/esm/plugins/stack-merge.js.map +1 -0
  49. package/dist/esm/plugins/status.d.ts +9 -0
  50. package/dist/esm/plugins/status.js +36 -0
  51. package/dist/esm/plugins/status.js.map +1 -0
  52. package/dist/esm/plugins/tags.d.ts +12 -0
  53. package/dist/esm/plugins/tags.js +49 -0
  54. package/dist/esm/plugins/tags.js.map +1 -0
  55. package/package.json +53 -23
  56. package/src/index.test.ts +696 -452
  57. package/src/index.ts +1178 -502
  58. package/src/plugins/cause.test.ts +106 -0
  59. package/src/plugins/cause.ts +45 -0
  60. package/src/plugins/code.test.ts +27 -0
  61. package/src/plugins/code.ts +20 -0
  62. package/src/plugins/expected.test.ts +66 -0
  63. package/src/plugins/expected.ts +48 -0
  64. package/src/plugins/message-merge.test.ts +32 -0
  65. package/src/plugins/message-merge.ts +19 -0
  66. package/src/plugins/meta.test.ts +32 -0
  67. package/src/plugins/meta.ts +59 -0
  68. package/src/plugins/stack-merge.test.ts +57 -0
  69. package/src/plugins/stack-merge.ts +20 -0
  70. package/src/plugins/status.test.ts +54 -0
  71. package/src/plugins/status.ts +35 -0
  72. package/src/plugins/tags.test.ts +74 -0
  73. package/src/plugins/tags.ts +51 -0
  74. package/dist/cjs/index.js +0 -435
  75. package/dist/cjs/index.js.map +0 -1
package/src/index.test.ts CHANGED
@@ -1,16 +1,15 @@
1
- import { describe, expect, it } from 'bun:test'
2
- import { type AxiosError, isAxiosError } from 'axios'
1
+ import { describe, expect, expectTypeOf, it } from 'bun:test'
2
+ import * as assert from 'node:assert'
3
3
  import z, { ZodError } from 'zod'
4
- import { Error0, e0s } from './index.js'
5
-
6
- // TODO: test expected
4
+ import type { ClassError0 } from './index.js'
5
+ import { Error0 } from './index.js'
7
6
 
8
7
  const fixStack = (stack: string | undefined) => {
9
8
  if (!stack) {
10
9
  return stack
11
10
  }
12
- // at <anonymous> (/Users/iserdmi/cc/projects/svagatron/modules/lib/error0.test.ts:103:25)
13
- // >>
11
+ // at <anonymous> (/Users/x/error0.test.ts:103:25)
12
+ //
14
13
  // at <anonymous> (...)
15
14
  const lines = stack.split('\n')
16
15
  const fixedLines = lines.map((line) => {
@@ -20,522 +19,767 @@ const fixStack = (stack: string | undefined) => {
20
19
  return fixedLines.join('\n')
21
20
  }
22
21
 
23
- const toJSON = (error: Error0) => {
24
- const result = error.toJSON()
25
- result.stack = fixStack(error.stack)
26
- return result
27
- }
22
+ describe('Error0', () => {
23
+ const statusPlugin = Error0.plugin()
24
+ .prop('status', {
25
+ init: (input: number) => input,
26
+ resolve: ({ flow }) => flow.find(Boolean),
27
+ serialize: ({ resolved }) => resolved,
28
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
29
+ })
30
+ .method('isStatus', (error, status: number) => error.status === status)
31
+
32
+ const codes = ['NOT_FOUND', 'BAD_REQUEST', 'UNAUTHORIZED'] as const
33
+ type Code = (typeof codes)[number]
34
+ const codePlugin = Error0.plugin().use('prop', 'code', {
35
+ init: (input: Code) => input,
36
+ resolve: ({ flow }) => flow.find(Boolean),
37
+ serialize: ({ resolved, isPublic }) => (isPublic ? undefined : resolved),
38
+ deserialize: ({ value }) =>
39
+ typeof value === 'string' && codes.includes(value as Code) ? (value as Code) : undefined,
40
+ })
28
41
 
29
- describe('error0', () => {
30
42
  it('simple', () => {
31
- const error0 = new Error0('test')
32
- expect(error0).toBeInstanceOf(Error0)
33
- expect(error0).toMatchInlineSnapshot(`[Error0: test]`)
34
- expect(toJSON(error0)).toMatchInlineSnapshot(`
35
- {
36
- "__I_AM_ERROR_0": true,
37
- "anyMessage": undefined,
38
- "cause": undefined,
39
- "clientMessage": undefined,
40
- "code": undefined,
41
- "expected": false,
42
- "httpStatus": undefined,
43
- "message": "test",
44
- "meta": {},
45
- "stack":
43
+ const error = new Error0('test')
44
+ expect(error).toBeInstanceOf(Error0)
45
+ expect(error).toBeInstanceOf(Error)
46
+ expect(error).toMatchInlineSnapshot(`[Error0: test]`)
47
+ expect(error.message).toBe('test')
48
+ expect(error.stack).toBeDefined()
49
+ expect(fixStack(error.stack)).toMatchInlineSnapshot(`
46
50
  "Error0: test
47
51
  at <anonymous> (...)"
48
- ,
49
- "tag": undefined,
50
- }
51
52
  `)
52
53
  })
53
54
 
54
- it('full', () => {
55
- const input = {
56
- message: 'my message',
57
- tag: 'tag1',
58
- code: 'code1',
59
- httpStatus: 400,
60
- expected: true,
61
- clientMessage: 'human message 1',
62
- cause: new Error('original message'),
63
- meta: {
64
- reqDurationMs: 1,
65
- userId: 'user1',
66
- },
67
- }
68
- const error1 = new Error0(input)
69
- const error2 = new Error0(input.message, input)
70
- expect(toJSON(error1)).toMatchObject(toJSON(error2))
71
- expect(toJSON(error1)).toMatchInlineSnapshot(`
72
- {
73
- "__I_AM_ERROR_0": true,
74
- "anyMessage": undefined,
75
- "cause": [Error: original message],
76
- "clientMessage": "human message 1",
77
- "code": "code1",
78
- "expected": true,
79
- "httpStatus": 400,
80
- "message": "my message",
81
- "meta": {
82
- "reqDurationMs": 1,
83
- "userId": "user1",
84
- },
85
- "stack":
86
- "Error0: my message
87
- at <anonymous> (...)
88
-
89
- Error: original message
55
+ it('with direct prop plugin', () => {
56
+ const AppError = Error0.use('prop', 'status', {
57
+ init: (input: number) => input,
58
+ resolve: ({ flow }) => flow.find(Boolean),
59
+ serialize: ({ resolved }) => resolved,
60
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
61
+ })
62
+ const error = new AppError('test', { status: 400 })
63
+ expect(error).toBeInstanceOf(AppError)
64
+ expect(error).toBeInstanceOf(Error0)
65
+ expect(error).toBeInstanceOf(Error)
66
+ expect(error.status).toBe(400)
67
+ expect(error).toMatchInlineSnapshot(`[Error0: test]`)
68
+ expect(error.stack).toBeDefined()
69
+ expect(fixStack(error.stack)).toMatchInlineSnapshot(`
70
+ "Error0: test
90
71
  at <anonymous> (...)"
91
- ,
92
- "tag": "tag1",
93
- }
94
72
  `)
73
+ expectTypeOf<typeof AppError>().toExtend<ClassError0>()
95
74
  })
96
75
 
97
- it('cause error default', () => {
98
- const errorDefault = new Error('original message')
99
- const error0 = new Error0('my message', { cause: errorDefault })
100
- expect(error0).toBeInstanceOf(Error0)
101
- expect(error0).toMatchInlineSnapshot(`[Error0: my message]`)
102
- expect(toJSON(error0)).toMatchInlineSnapshot(`
103
- {
104
- "__I_AM_ERROR_0": true,
105
- "anyMessage": undefined,
106
- "cause": [Error: original message],
107
- "clientMessage": undefined,
108
- "code": undefined,
109
- "expected": false,
110
- "httpStatus": undefined,
111
- "message": "my message",
112
- "meta": {},
113
- "stack":
114
- "Error0: my message
115
- at <anonymous> (...)
76
+ it('class helpers prop/method/adapt mirror use API', () => {
77
+ const AppError = Error0.use('prop', 'status', {
78
+ init: (value: number) => value,
79
+ resolve: ({ own, flow }) => {
80
+ return typeof own === 'number' ? own : undefined
81
+ },
82
+ serialize: ({ resolved }) => resolved,
83
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
84
+ })
85
+ .use('method', 'isStatus', (error, expectedStatus: number) => error.status === expectedStatus)
86
+ .use('adapt', (error) => {
87
+ if (error.cause instanceof Error && error.status === undefined) {
88
+ return { status: 500 }
89
+ }
90
+ return undefined
91
+ })
92
+
93
+ const error = AppError.from(new Error('inner'))
94
+ expect(error.status).toBe(500)
95
+ expect(error.isStatus(500)).toBe(true)
96
+ expect(AppError.isStatus(error, 500)).toBe(true)
97
+ expectTypeOf<typeof AppError>().toExtend<ClassError0>()
98
+ })
116
99
 
117
- Error: original message
100
+ it('with defined plugin', () => {
101
+ const AppError = Error0.use(statusPlugin)
102
+ const error = new AppError('test', { status: 400 })
103
+ expect(error).toBeInstanceOf(AppError)
104
+ expect(error).toBeInstanceOf(Error0)
105
+ expect(error).toBeInstanceOf(Error)
106
+ expect(error.status).toBe(400)
107
+ expect(error).toMatchInlineSnapshot(`[Error0: test]`)
108
+ expect(error.stack).toBeDefined()
109
+ expect(fixStack(error.stack)).toMatchInlineSnapshot(`
110
+ "Error0: test
118
111
  at <anonymous> (...)"
119
- ,
120
- "tag": undefined,
121
- }
122
112
  `)
123
113
  })
124
114
 
125
- it('cause strange thing', () => {
126
- const error0 = new Error0('my message', { cause: 'strange thing' })
127
- expect(error0).toMatchInlineSnapshot(`[Error0: my message]`)
128
- expect(toJSON(error0)).toMatchInlineSnapshot(`
129
- {
130
- "__I_AM_ERROR_0": true,
131
- "anyMessage": undefined,
132
- "cause": "strange thing",
133
- "clientMessage": undefined,
134
- "code": undefined,
135
- "expected": false,
136
- "httpStatus": undefined,
137
- "message": "my message",
138
- "meta": {},
139
- "stack":
140
- "Error0: my message
115
+ it('twice used Error0 extends previous by types', () => {
116
+ const AppError1 = Error0.use(statusPlugin)
117
+ const AppError2 = AppError1.use(codePlugin)
118
+ const error1 = new AppError1('test', { status: 400 })
119
+ const error2 = new AppError2('test', { status: 400, code: 'NOT_FOUND' })
120
+ expect(error1.status).toBe(400)
121
+ expect(error2.status).toBe(400)
122
+ expect(error2.code).toBe('NOT_FOUND')
123
+ expectTypeOf<typeof error2.status>().toEqualTypeOf<number | undefined>()
124
+ expectTypeOf<typeof error2.code>().toEqualTypeOf<'NOT_FOUND' | 'BAD_REQUEST' | 'UNAUTHORIZED' | undefined>()
125
+ expectTypeOf<typeof AppError1>().toExtend<ClassError0>()
126
+ expectTypeOf<typeof AppError2>().toExtend<ClassError0>()
127
+ expectTypeOf<typeof AppError2>().toExtend<typeof AppError1>()
128
+ expectTypeOf<typeof AppError1>().not.toExtend<typeof AppError2>()
129
+ })
130
+
131
+ it('can have cause', () => {
132
+ const AppError = Error0.use(statusPlugin)
133
+ const anotherError = new Error('another error')
134
+ const error = new AppError('test', { status: 400, cause: anotherError })
135
+ expect(error.status).toBe(400)
136
+ expect(error).toMatchInlineSnapshot(`[Error0: test]`)
137
+ expect(error.stack).toBeDefined()
138
+ expect(fixStack(error.stack)).toMatchInlineSnapshot(`
139
+ "Error0: test
141
140
  at <anonymous> (...)"
142
- ,
143
- "tag": undefined,
144
- }
145
141
  `)
142
+ expect(Error0.causes(error)).toEqual([error, anotherError])
146
143
  })
147
144
 
148
- it('floats and overrides', () => {
149
- const error01 = new Error0('first', {
150
- tag: 'tag1',
151
- clientMessage: 'human message 1',
152
- meta: {
153
- reqDurationMs: 1,
154
- userId: 'user1',
155
- },
145
+ it('can have many causes', () => {
146
+ const AppError = Error0.use(statusPlugin)
147
+ const anotherError = new Error('another error')
148
+ const error1 = new AppError('test1', { status: 400, cause: anotherError })
149
+ const error2 = new AppError('test2', { status: 400, cause: error1 })
150
+ expect(error1.status).toBe(400)
151
+ expect(error2.status).toBe(400)
152
+ expect(Error0.causes(error2)).toEqual([error2, error1, anotherError])
153
+ })
154
+
155
+ it('can limit causes depth via MAX_CAUSES_DEPTH on class', () => {
156
+ const AppError = Error0.use(statusPlugin)
157
+ const base = new AppError('base', { status: 400 })
158
+ const level1 = new AppError('level1', { status: 401, cause: base })
159
+ const level2 = new AppError('level2', { status: 402, cause: level1 })
160
+
161
+ AppError.MAX_CAUSES_DEPTH = 2
162
+ expect(AppError.causes(level2)).toEqual([level2, level1])
163
+
164
+ AppError.MAX_CAUSES_DEPTH = 999
165
+ expect(AppError.causes(level2)).toEqual([level2, level1, base])
166
+ })
167
+
168
+ it('properties floating', () => {
169
+ const AppError = Error0.use(statusPlugin).use(codePlugin)
170
+ const anotherError = new Error('another error')
171
+ const error1 = new AppError('test1', { status: 400, cause: anotherError })
172
+ const error2 = new AppError('test2', { code: 'NOT_FOUND', cause: error1 })
173
+ expect(error1.status).toBe(400)
174
+ expect(error1.code).toBe(undefined)
175
+ expect(error2.status).toBe(400)
176
+ expect(error2.code).toBe('NOT_FOUND')
177
+ expect(Error0.causes(error2)).toEqual([error2, error1, anotherError])
178
+ })
179
+
180
+ it('property getter return resolved value, not own value', () => {
181
+ const AppError = Error0.use('prop', 'status', {
182
+ init: (input: number) => input,
183
+ resolve: () => 500,
184
+ serialize: ({ resolved }) => resolved,
185
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
156
186
  })
157
- const error02 = new Error0('second', {
158
- tag: 'tag2',
159
- code: 'code2',
160
- cause: error01,
161
- meta: {
162
- reqDurationMs: 1,
163
- ideaId: 'idea1',
164
- other: {
165
- x: 1,
166
- },
167
- },
187
+ const error = new AppError('another error', { status: 400 })
188
+ expect(error.status).toBe(500)
189
+ expect(error.own('status')).toBe(400)
190
+ expect(error.flow('status')).toEqual([400])
191
+ })
192
+
193
+ it('serialize uses identity by default and skips undefined plugin values', () => {
194
+ const AppError = Error0.use(statusPlugin).use('prop', 'code', {
195
+ init: (input: string) => input,
196
+ resolve: ({ flow }) => flow.find(Boolean),
197
+ serialize: () => undefined,
198
+ deserialize: ({ value }) => (typeof value === 'string' ? value : undefined),
168
199
  })
169
- expect(error01).toBeInstanceOf(Error0)
170
- expect(toJSON(error02)).toMatchInlineSnapshot(`
171
- {
172
- "__I_AM_ERROR_0": true,
173
- "anyMessage": undefined,
174
- "cause": [Error0: first],
175
- "clientMessage": "human message 1",
176
- "code": "code2",
177
- "expected": false,
178
- "httpStatus": undefined,
179
- "message": "second",
180
- "meta": {
181
- "ideaId": "idea1",
182
- "other": {
183
- "x": 1,
184
- },
185
- "reqDurationMs": 1,
186
- "userId": "user1",
187
- },
188
- "stack":
189
- "Error0: second
190
- at <anonymous> (...)
200
+ const error = new AppError('test', { status: 401, code: 'secret' })
201
+ const json = AppError.serialize(error)
202
+ expect(json.status).toBe(401)
203
+ expect('code' in json).toBe(false)
204
+ })
191
205
 
192
- Error0: first
193
- at <anonymous> (...)"
194
- ,
195
- "tag": "tag2",
196
- }
197
- `)
206
+ it('serialize keeps stack by default without stack plugin when not public', () => {
207
+ const AppError = Error0.use(statusPlugin)
208
+ const error = new AppError('test', { status: 500 })
209
+ const json = AppError.serialize(error, false)
210
+ expect(json.stack).toBe(error.stack)
198
211
  })
199
212
 
200
- it('unknown error', () => {
201
- const error0 = new Error0({})
202
- expect(toJSON(error0)).toMatchInlineSnapshot(`
203
- {
204
- "__I_AM_ERROR_0": true,
205
- "anyMessage": undefined,
206
- "cause": undefined,
207
- "clientMessage": undefined,
208
- "code": undefined,
209
- "expected": false,
210
- "httpStatus": undefined,
211
- "message": "Unknown error",
212
- "meta": {},
213
- "stack":
214
- "Error0: Unknown error
215
- at <anonymous> (...)"
216
- ,
217
- "tag": undefined,
218
- }
219
- `)
220
- const error1 = new Error0('test')
221
- expect(error1.message).toBe('test')
222
- const error2 = new Error0({ cause: error1 })
223
- expect(toJSON(error2)).toMatchInlineSnapshot(`
224
- {
225
- "__I_AM_ERROR_0": true,
226
- "anyMessage": undefined,
227
- "cause": [Error0: test],
228
- "clientMessage": undefined,
229
- "code": undefined,
230
- "expected": false,
231
- "httpStatus": undefined,
232
- "message": "Unknown error",
233
- "meta": {},
234
- "stack":
235
- "Error0: Unknown error
236
- at <anonymous> (...)
213
+ it('serialize does not keep stack when public', () => {
214
+ const AppError = Error0.use(statusPlugin)
215
+ const error = new AppError('test', { status: 500 })
216
+ const json = AppError.serialize(error, true)
217
+ expect('stack' in json).toBe(false)
218
+ })
237
219
 
238
- Error0: test
239
- at <anonymous> (...)"
240
- ,
241
- "tag": undefined,
242
- }
243
- `)
244
- expect(fixStack(error2.stack)).toMatchInlineSnapshot(`
245
- "Error0: Unknown error
246
- at <anonymous> (...)
220
+ it('stack plugin can customize stack serialization without defining prop plugin', () => {
221
+ const AppError = Error0.use('stack', { serialize: ({ value }) => (value ? `custom:${value}` : undefined) })
222
+ const error = new AppError('test')
223
+ const json = AppError.serialize(error)
224
+ expect(typeof json.stack).toBe('string')
225
+ expect((json.stack as string).startsWith('custom:')).toBe(true)
226
+ })
247
227
 
248
- Error0: test
249
- at <anonymous> (...)"
250
- `)
228
+ it('stack plugin can keep default stack via identity function', () => {
229
+ const AppError = Error0.use('stack', { serialize: ({ value }) => value })
230
+ const error = new AppError('test')
231
+ const json = AppError.serialize(error)
232
+ expect(json.stack).toBe(error.stack)
251
233
  })
252
234
 
253
- it('input error default', () => {
254
- const errorDefault = new Error('default error')
255
- const error0 = new Error0(errorDefault)
256
- expect(toJSON(error0)).toMatchInlineSnapshot(`
257
- {
258
- "__I_AM_ERROR_0": true,
259
- "anyMessage": undefined,
260
- "cause": [Error: default error],
261
- "clientMessage": undefined,
262
- "code": undefined,
263
- "expected": false,
264
- "httpStatus": undefined,
265
- "message": "Unknown error",
266
- "meta": {},
267
- "stack":
268
- "Error0: Unknown error
269
- at <anonymous> (...)
235
+ it('stack plugin can disable stack serialization via function', () => {
236
+ const AppError = Error0.use('stack', { serialize: () => undefined })
237
+ const error = new AppError('test')
238
+ const json = AppError.serialize(error)
239
+ expect('stack' in json).toBe(false)
240
+ })
270
241
 
271
- Error: default error
272
- at <anonymous> (...)"
273
- ,
274
- "tag": undefined,
275
- }
276
- `)
277
- expect(fixStack(error0.stack)).toMatchInlineSnapshot(`
278
- "Error0: Unknown error
279
- at <anonymous> (...)
242
+ it('stack plugin rejects boolean config', () => {
243
+ expect(() => Error0.use('stack', true as any)).toThrow('expects { serialize: function }')
244
+ })
280
245
 
281
- Error: default error
282
- at <anonymous> (...)"
283
- `)
246
+ it('message plugin rejects boolean config', () => {
247
+ expect(() => Error0.use('message', true as any)).toThrow('expects { serialize: function }')
284
248
  })
285
249
 
286
- it('input error0 itself', () => {
287
- const error = new Error0('error0 error')
288
- const error0 = new Error0(error)
289
- expect(toJSON(error0)).toMatchInlineSnapshot(`
290
- {
291
- "__I_AM_ERROR_0": true,
292
- "anyMessage": undefined,
293
- "cause": [Error0: error0 error],
294
- "clientMessage": undefined,
295
- "code": undefined,
296
- "expected": false,
297
- "httpStatus": undefined,
298
- "message": "Unknown error",
299
- "meta": {},
300
- "stack":
301
- "Error0: Unknown error
302
- at <anonymous> (...)
250
+ it('prop("stack") throws and suggests using stack plugin', () => {
251
+ expect(() =>
252
+ Error0.use('prop', 'stack', {
253
+ init: (input: string) => input,
254
+ resolve: ({ own }) => (typeof own === 'string' ? own : undefined),
255
+ serialize: ({ resolved }) => resolved,
256
+ deserialize: ({ value }) => (typeof value === 'string' ? value : undefined),
257
+ }),
258
+ ).toThrow('reserved prop key')
259
+ })
303
260
 
304
- Error0: error0 error
305
- at <anonymous> (...)"
306
- ,
307
- "tag": undefined,
308
- }
309
- `)
310
- expect(fixStack(error0.stack)).toMatchInlineSnapshot(`
311
- "Error0: Unknown error
312
- at <anonymous> (...)
261
+ it('plugin builder also rejects prop("stack") as reserved key', () => {
262
+ expect(() =>
263
+ Error0.plugin().prop('stack', {
264
+ init: (input: string) => input,
265
+ resolve: ({ own }) => (typeof own === 'string' ? own : undefined),
266
+ serialize: ({ resolved }) => resolved,
267
+ deserialize: ({ value }) => (typeof value === 'string' ? value : undefined),
268
+ }),
269
+ ).toThrow('reserved prop key')
270
+ })
313
271
 
314
- Error0: error0 error
315
- at <anonymous> (...)"
316
- `)
272
+ it('prop("message") throws and suggests using message plugin', () => {
273
+ expect(() =>
274
+ Error0.use('prop', 'message', {
275
+ resolve: ({ own }) => own as string,
276
+ serialize: ({ resolved }) => resolved,
277
+ deserialize: ({ value }) => (typeof value === 'string' ? value : undefined),
278
+ }),
279
+ ).toThrow('reserved prop key')
317
280
  })
318
281
 
319
- it('keep stack trace', () => {
320
- const errorDefault = new Error('default error')
321
- const error01 = new Error0('first', {
322
- tag: 'tag1',
323
- clientMessage: 'human message 1',
324
- cause: errorDefault,
282
+ it('plugin builder also rejects prop("message") as reserved key', () => {
283
+ expect(() =>
284
+ Error0.plugin().prop('message', {
285
+ resolve: ({ own }) => own as string,
286
+ serialize: ({ resolved }) => resolved,
287
+ deserialize: ({ value }) => (typeof value === 'string' ? value : undefined),
288
+ }),
289
+ ).toThrow('reserved prop key')
290
+ })
291
+
292
+ it('.serialize() -> .from() roundtrip keeps plugin values', () => {
293
+ const AppError = Error0.use(statusPlugin).use(codePlugin)
294
+ const error = new AppError('test', { status: 409, code: 'NOT_FOUND' })
295
+ const json = AppError.serialize(error, false)
296
+ const recreated = AppError.from(json)
297
+ expect(recreated).toBeInstanceOf(AppError)
298
+ expect(recreated.status).toBe(409)
299
+ expect(recreated.code).toBe('NOT_FOUND')
300
+ expect(AppError.serialize(recreated, false)).toEqual(json)
301
+ })
302
+
303
+ it('.round() static and instance do serialize/from roundtrip', () => {
304
+ const AppError = Error0.use(statusPlugin).use(codePlugin)
305
+ const error = new AppError('test', { status: 409, code: 'NOT_FOUND' })
306
+ const roundedStatic = AppError.round(error, false)
307
+ const roundedInstance = error.round(false)
308
+
309
+ expect(roundedStatic).toBeInstanceOf(AppError)
310
+ expect(roundedInstance).toBeInstanceOf(AppError)
311
+ expect(roundedStatic.status).toBe(409)
312
+ expect(roundedStatic.code).toBe('NOT_FOUND')
313
+ expect(roundedInstance.status).toBe(409)
314
+ expect(roundedInstance.code).toBe('NOT_FOUND')
315
+ expectTypeOf(roundedStatic.status).toEqualTypeOf<number | undefined>()
316
+ expectTypeOf(roundedStatic.code).toEqualTypeOf<'NOT_FOUND' | 'BAD_REQUEST' | 'UNAUTHORIZED' | undefined>()
317
+ expectTypeOf(roundedInstance.status).toEqualTypeOf<number | undefined>()
318
+ expectTypeOf(roundedInstance.code).toEqualTypeOf<'NOT_FOUND' | 'BAD_REQUEST' | 'UNAUTHORIZED' | undefined>()
319
+ })
320
+
321
+ it('.serialize() floated props and not serialize causes', () => {
322
+ const AppError = Error0.use(statusPlugin).use(codePlugin)
323
+ const error1 = new AppError('test', { status: 409 })
324
+ const error2 = new AppError('test', { code: 'NOT_FOUND', cause: error1 })
325
+ const json = AppError.serialize(error2, false)
326
+ expect(json.status).toBe(409)
327
+ expect(json.code).toBe('NOT_FOUND')
328
+ expect('cause' in json).toBe(false)
329
+ })
330
+
331
+ it('by default causes not serialized', () => {
332
+ const AppError = Error0.use(statusPlugin).use(codePlugin)
333
+ const error = new AppError('test', { status: 400, code: 'NOT_FOUND' })
334
+ const json = AppError.serialize(error, false)
335
+ expect('cause' in json).toBe(false)
336
+ })
337
+
338
+ it('serialize can hide props for public output', () => {
339
+ const AppError = Error0.use(statusPlugin).use(codePlugin)
340
+ const error = new AppError('test', { status: 401, code: 'NOT_FOUND' })
341
+ const privateJson = AppError.serialize(error, false)
342
+ const publicJson = AppError.serialize(error, true)
343
+ expect(privateJson.code).toBe('NOT_FOUND')
344
+ expect('code' in publicJson).toBe(false)
345
+ })
346
+
347
+ it('prop init without input arg infers undefined-only constructor input', () => {
348
+ const AppError = Error0.use('prop', 'computed', {
349
+ init: () => undefined as number | undefined,
350
+ resolve: ({ flow }) => flow.find((item) => typeof item === 'number'),
351
+ serialize: ({ resolved }) => resolved,
352
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
325
353
  })
326
- const error02 = new Error0('second', {
327
- tag: 'tag2',
328
- code: 'code2',
329
- cause: error01,
354
+
355
+ const error = new AppError('test')
356
+ expect(error.computed).toBe(undefined)
357
+ expectTypeOf<typeof error.computed>().toEqualTypeOf<number | undefined>()
358
+
359
+ // @ts-expect-error - computed input is disallowed when init has no input arg
360
+ // eslint-disable-next-line no-new
361
+ new AppError('test', { computed: 123 })
362
+ })
363
+
364
+ it('prop without init omits constructor input and infers resolve output', () => {
365
+ const AppError = Error0.use('prop', 'statusCode', {
366
+ resolve: ({ flow }) => flow.find((item) => typeof item === 'number'),
367
+ serialize: ({ resolved }) => resolved,
368
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
330
369
  })
331
- expect(fixStack(errorDefault.stack)).toMatchInlineSnapshot(`
332
- "Error: default error
333
- at <anonymous> (...)"
334
- `)
335
- expect(fixStack(error01.stack)).toMatchInlineSnapshot(`
336
- "Error0: first
337
- at <anonymous> (...)
338
370
 
339
- Error: default error
340
- at <anonymous> (...)"
341
- `)
342
- expect(fixStack(error02.stack)).toMatchInlineSnapshot(`
343
- "Error0: second
344
- at <anonymous> (...)
371
+ const error = new AppError('test')
372
+ expect(error.statusCode).toBe(undefined)
373
+ expectTypeOf<typeof error.statusCode>().toEqualTypeOf<number | undefined>()
345
374
 
346
- Error0: first
347
- at <anonymous> (...)
375
+ // @ts-expect-error - statusCode input is disallowed when init is omitted
376
+ // eslint-disable-next-line no-new
377
+ new AppError('test', { statusCode: 123 })
378
+ })
348
379
 
349
- Error: default error
350
- at <anonymous> (...)"
351
- `)
380
+ it('prop output type is inferred from resolve type', () => {
381
+ const AppError = Error0.use('prop', 'x', {
382
+ init: (input: number) => input,
383
+ resolve: ({ flow }) => flow.find((item) => typeof item === 'number') || 500,
384
+ serialize: ({ resolved }) => resolved,
385
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
386
+ })
387
+
388
+ const error = new AppError('test')
389
+ expect(error.x).toBe(500)
390
+ expectTypeOf<typeof error.x>().toEqualTypeOf<number>()
391
+ expectTypeOf(AppError.own(error, 'x')).toEqualTypeOf<number | undefined>()
392
+ expectTypeOf(AppError.flow(error, 'x')).toEqualTypeOf<Array<number | undefined>>()
393
+
394
+ Error0.plugin().prop('x', {
395
+ init: (input: number) => input,
396
+ // @ts-expect-error - resolve type extends init type
397
+ resolve: ({ flow }) => 'string',
398
+ serialize: ({ resolved }) => resolved,
399
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
400
+ })
352
401
  })
353
402
 
354
- it('expected', () => {
355
- const error0 = new Error0({
356
- expected: true,
403
+ it('own/flow are typed by output type, not resolve type', () => {
404
+ const AppError = Error0.use('prop', 'code', {
405
+ init: (input: number | 'fallback') => input,
406
+ resolve: ({ flow }) => flow.find((item) => typeof item === 'number') ?? 500,
407
+ serialize: ({ resolved }) => resolved,
408
+ deserialize: ({ value }) => (typeof value === 'number' || value === 'fallback' ? value : undefined),
357
409
  })
358
- expect(error0.expected).toBe(true)
410
+ const error = new AppError('test')
411
+
412
+ expect(error.code).toBe(500)
413
+ expect(AppError.own(error, 'code')).toBe(undefined)
414
+ expect(AppError.own(error)).toEqual({ code: undefined })
415
+ expect(error.own()).toEqual({ code: undefined })
416
+ expectTypeOf<typeof error.code>().toEqualTypeOf<number>()
417
+ expectTypeOf(AppError.own(error, 'code')).toEqualTypeOf<number | 'fallback' | undefined>()
418
+ expectTypeOf(AppError.own(error)).toEqualTypeOf<{ code: number | 'fallback' | undefined }>()
419
+ expectTypeOf(AppError.flow(error, 'code')).toEqualTypeOf<Array<number | 'fallback' | undefined>>()
420
+ })
359
421
 
360
- const error1 = new Error0({
361
- expected: false,
422
+ it('own/flow runtime behavior across causes', () => {
423
+ type Code = 'A' | 'B'
424
+ const isCode = (item: unknown): item is Code => item === 'A' || item === 'B'
425
+ const AppError = Error0.use('prop', 'status', {
426
+ init: (input: number) => input,
427
+ resolve: ({ flow }) => flow.find((item) => typeof item === 'number'),
428
+ serialize: ({ resolved }) => resolved,
429
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
430
+ }).use('prop', 'code', {
431
+ init: (input: Code) => input,
432
+ resolve: ({ flow }) => flow.find(isCode),
433
+ serialize: ({ resolved }) => resolved,
434
+ deserialize: ({ value }) => (value === 'A' || value === 'B' ? value : undefined),
362
435
  })
363
- expect(error1.expected).toBe(false)
364
436
 
365
- const error3 = new Error0({
366
- expected: true,
367
- cause: error0,
437
+ const root = new AppError('root', { status: 400, code: 'A' })
438
+ const mid = new AppError('mid', { cause: root })
439
+ const leaf = new AppError('leaf', { status: 500, cause: mid })
440
+
441
+ expect(leaf.own()).toEqual({ status: 500, code: undefined })
442
+ expect(AppError.own(leaf)).toEqual({ status: 500, code: undefined })
443
+ expect(leaf.flow('status')).toEqual([500, undefined, 400])
444
+ expect(AppError.flow(leaf, 'status')).toEqual([500, undefined, 400])
445
+ expect(leaf.flow('code')).toEqual([undefined, undefined, 'A'])
446
+ expect(AppError.flow(leaf, 'code')).toEqual([undefined, undefined, 'A'])
447
+ })
448
+
449
+ it('own/flow have strong types for static and instance methods', () => {
450
+ type Code = 'A' | 'B'
451
+ const isCode = (item: unknown): item is Code => item === 'A' || item === 'B'
452
+ const AppError = Error0.use('prop', 'status', {
453
+ init: (input: number) => input,
454
+ resolve: ({ flow }) => flow.find((item) => typeof item === 'number'),
455
+ serialize: ({ resolved }) => resolved,
456
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
457
+ }).use('prop', 'code', {
458
+ init: (input: Code) => input,
459
+ resolve: ({ flow }) => flow.find(isCode),
460
+ serialize: ({ resolved }) => resolved,
461
+ deserialize: ({ value }) => (value === 'A' || value === 'B' ? value : undefined),
368
462
  })
369
- expect(error3.expected).toBe(true)
370
463
 
371
- const error4 = new Error0({
372
- expected: false,
373
- cause: error0,
464
+ const error = new AppError('test', { status: 400, code: 'A' })
465
+
466
+ expectTypeOf(error.own('status')).toEqualTypeOf<number | undefined>()
467
+ expectTypeOf(error.own('code')).toEqualTypeOf<Code | undefined>()
468
+
469
+ expectTypeOf(AppError.own(error, 'status')).toEqualTypeOf<number | undefined>()
470
+ expectTypeOf(AppError.own(error, 'code')).toEqualTypeOf<Code | undefined>()
471
+ expectTypeOf(AppError.own(error)).toEqualTypeOf<{ status: number | undefined; code: Code | undefined }>()
472
+ expectTypeOf(AppError.flow(error, 'status')).toEqualTypeOf<Array<number | undefined>>()
473
+ expectTypeOf(AppError.flow(error, 'code')).toEqualTypeOf<Array<Code | undefined>>()
474
+ })
475
+
476
+ it('resolve returns plain resolved props object without methods', () => {
477
+ type Code = 'A' | 'B'
478
+ const isCode = (item: unknown): item is Code => item === 'A' || item === 'B'
479
+ const AppError = Error0.use('prop', 'status', {
480
+ init: (input: number) => input,
481
+ resolve: ({ flow }) => flow.find((item) => typeof item === 'number') ?? 500,
482
+ serialize: ({ resolved }) => resolved,
483
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
374
484
  })
375
- expect(error4.expected).toBe(false)
485
+ .use('prop', 'code', {
486
+ init: (input: Code) => input,
487
+ resolve: ({ flow }) => flow.find(isCode),
488
+ serialize: ({ resolved }) => resolved,
489
+ deserialize: ({ value }) => (value === 'A' || value === 'B' ? value : undefined),
490
+ })
491
+ .use('method', 'isStatus', (error, status: number) => error.status === status)
492
+
493
+ const root = new AppError('root', { status: 400, code: 'A' })
494
+ const leaf = new AppError('leaf', { cause: root })
495
+
496
+ const resolvedStatic = AppError.resolve(leaf)
497
+ const resolvedInstance = leaf.resolve()
498
+ expect(resolvedStatic).toEqual({ status: 400, code: 'A' })
499
+ expect(resolvedInstance).toEqual({ status: 400, code: 'A' })
500
+ expect('isStatus' in resolvedStatic).toBe(false)
501
+ expect(Object.keys(resolvedInstance)).toEqual(['status', 'code'])
502
+
503
+ expectTypeOf(resolvedStatic).toEqualTypeOf<{ status: number; code: Code | undefined }>()
504
+ })
376
505
 
377
- const error5 = new Error0({
378
- expected: true,
379
- cause: error1,
506
+ it('prop resolved type can be not undefined with init not provided', () => {
507
+ const AppError = Error0.use('prop', 'x', {
508
+ resolve: ({ flow }) => flow.find((item) => typeof item === 'number') || 500,
509
+ serialize: ({ resolved }) => resolved,
510
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
380
511
  })
381
- expect(error5.expected).toBe(false)
382
512
 
383
- const error6 = new Error0({
384
- expected: false,
385
- cause: error1,
513
+ const error = new AppError('test')
514
+ expect(error.x).toBe(500)
515
+ expectTypeOf<typeof error.x>().toEqualTypeOf<number>()
516
+
517
+ Error0.plugin().prop('x', {
518
+ init: (input: number) => input,
519
+ // @ts-expect-error - resolve type extends init type
520
+ resolve: ({ flow }) => 'string',
521
+ serialize: ({ resolved }) => resolved,
522
+ deserialize: ({ value }) => (typeof value === 'number' ? value : undefined),
386
523
  })
387
- expect(error6.expected).toBe(false)
388
- })
389
-
390
- it('extends self', () => {
391
- const error7 = new e0s.Expected('expected error')
392
- expect(e0s.Expected.defaultExpected).toBe(true)
393
- expect(error7.expected).toBe(true)
394
- expect(error7).toBeInstanceOf(e0s.Expected)
395
- expect(error7).toBeInstanceOf(Error0)
396
- expect(toJSON(error7)).toMatchInlineSnapshot(`
397
- {
398
- "__I_AM_ERROR_0": true,
399
- "anyMessage": undefined,
400
- "cause": undefined,
401
- "clientMessage": undefined,
402
- "code": undefined,
403
- "expected": true,
404
- "httpStatus": undefined,
405
- "message": "expected error",
406
- "meta": {},
407
- "stack":
408
- "Error0: expected error
409
- at <anonymous> (...)"
410
- ,
411
- "tag": undefined,
412
- }
413
- `)
414
524
  })
415
525
 
416
- it('extend collection', () => {
417
- const e0s1 = Error0.extendCollection(e0s, {
418
- defaultMessage: 'nested error',
419
- defaultMeta: {
420
- tagPrefix: 'nested',
421
- },
526
+ it('serialize/deserialize can be set to false to disable them', () => {
527
+ const AppError = Error0.use('prop', 'status', {
528
+ init: (input: number) => input,
529
+ resolve: ({ flow }) => flow.find((item) => typeof item === 'number'),
530
+ serialize: false,
531
+ deserialize: false,
422
532
  })
423
- const error0 = new e0s1.Default('nested error')
424
- expect(error0).toBeInstanceOf(e0s1.Default)
425
- expect(error0).toBeInstanceOf(e0s.Default)
426
- expect(error0).toBeInstanceOf(Error0)
427
- expect(toJSON(error0)).toMatchInlineSnapshot(`
428
- {
429
- "__I_AM_ERROR_0": true,
430
- "anyMessage": undefined,
431
- "cause": undefined,
432
- "clientMessage": undefined,
433
- "code": undefined,
434
- "expected": false,
435
- "httpStatus": undefined,
436
- "message": "nested error",
437
- "meta": {
438
- "tagPrefix": "nested",
439
- },
440
- "stack":
441
- "Error0: nested error
533
+ const error = new AppError('test', { status: 401 })
534
+ const json = AppError.serialize(error)
535
+ expect('status' in json).toBe(false)
536
+
537
+ const recreated = AppError.from({ ...json, status: 999 })
538
+ expect(recreated.status).toBe(undefined)
539
+ })
540
+
541
+ it('by default error0 created from another error has same message', () => {
542
+ const schema = z.object({
543
+ x: z.string(),
544
+ })
545
+ const parseResult = schema.safeParse({ x: 123 })
546
+ const parsedError = parseResult.error
547
+ assert.ok(parsedError)
548
+ const error = Error0.from(parsedError)
549
+ expect(error.message).toBe(parsedError.message)
550
+ })
551
+
552
+ it('adapt message and other props via direct transformations', () => {
553
+ const schema = z.object({
554
+ x: z.string(),
555
+ })
556
+ const parseResult = schema.safeParse({ x: 123 })
557
+ const parsedError = parseResult.error
558
+ assert.ok(parsedError)
559
+ const AppError = Error0.use(statusPlugin)
560
+ .use(codePlugin)
561
+ .use('adapt', (error) => {
562
+ if (error.cause instanceof ZodError) {
563
+ error.status = 422
564
+ error.code = 'NOT_FOUND'
565
+ error.message = `Validation Error: ${error.message}`
566
+ }
567
+ })
568
+ const error = AppError.from(parsedError)
569
+ expect(error.message).toBe(`Validation Error: ${parsedError.message}`)
570
+ expect(error.status).toBe(422)
571
+ expect(error.code).toBe('NOT_FOUND')
572
+ const error1 = new AppError('test', { cause: parsedError })
573
+ expect(error1.message).toBe('test')
574
+ expect(error1.status).toBe(undefined)
575
+ expect(error1.code).toBe(undefined)
576
+ })
577
+
578
+ it('adapt message and other props via return output values from plugin', () => {
579
+ const schema = z.object({
580
+ x: z.string(),
581
+ })
582
+ const parseResult = schema.safeParse({ x: 123 })
583
+ const parsedError = parseResult.error
584
+ assert.ok(parsedError)
585
+ const AppError = Error0.use(statusPlugin)
586
+ .use(codePlugin)
587
+ .use('adapt', (error) => {
588
+ if (error.cause instanceof ZodError) {
589
+ error.message = `Validation Error: ${error.message}`
590
+ return {
591
+ status: 422,
592
+ code: 'NOT_FOUND',
593
+ }
594
+ }
595
+ return undefined
596
+ })
597
+ const error = AppError.from(parsedError)
598
+ expect(error.message).toBe(`Validation Error: ${parsedError.message}`)
599
+ expect(error.status).toBe(422)
600
+ expect(error.code).toBe('NOT_FOUND')
601
+ const error1 = new AppError('test', { cause: parsedError })
602
+ expect(error1.message).toBe('test')
603
+ expect(error1.status).toBe(undefined)
604
+ expect(error1.code).toBe(undefined)
605
+ })
606
+
607
+ it('messages can be combined on serialization', () => {
608
+ const AppError = Error0.use(statusPlugin)
609
+ .use(codePlugin)
610
+ .use('message', {
611
+ serialize: ({ error }) =>
612
+ error
613
+ .causes()
614
+ .map((cause) => {
615
+ return cause instanceof Error ? cause.message : undefined
616
+ })
617
+ .filter((value): value is string => typeof value === 'string')
618
+ .join(': '),
619
+ })
620
+ const error1 = new AppError('test1', { status: 400, code: 'NOT_FOUND' })
621
+ const error2 = new AppError({ message: 'test2', status: 401, cause: error1 })
622
+ expect(error1.message).toEqual('test1')
623
+ expect(error2.message).toEqual('test2')
624
+ expect((error2.cause as any)?.message).toEqual('test1')
625
+ expect(error1.serialize().message).toEqual('test1')
626
+ expect(error2.serialize().message).toEqual('test2: test1')
627
+ })
628
+
629
+ it('stack plugin can merge stack across causes in one serialized value', () => {
630
+ const AppError = Error0.use(statusPlugin)
631
+ .use(codePlugin)
632
+ .use('stack', {
633
+ serialize: ({ error }) =>
634
+ error
635
+ .causes()
636
+ .map((cause) => {
637
+ return cause instanceof Error ? cause.stack : undefined
638
+ })
639
+ .filter((value): value is string => typeof value === 'string')
640
+ .join('\n'),
641
+ })
642
+ const error1 = new AppError('test1', { status: 400, code: 'NOT_FOUND' })
643
+ const error2 = new AppError('test2', { status: 401, cause: error1 })
644
+ const mergedStack1 = error1.serialize().stack as string
645
+ const mergedStack2 = error2.serialize().stack as string
646
+ expect(mergedStack1).toContain('Error0: test1')
647
+ expect(mergedStack2).toContain('Error0: test2')
648
+ expect(mergedStack2).toContain('Error0: test1')
649
+ expect(fixStack(mergedStack1)).toMatchInlineSnapshot(`
650
+ "Error0: test1
442
651
  at <anonymous> (...)"
443
- ,
444
- "tag": "nested:nested",
445
- }
446
652
  `)
447
-
448
- const error02 = new e0s1.Expected('nested error 1')
449
- expect(error02).toBeInstanceOf(e0s1.Expected)
450
- expect(error02).toBeInstanceOf(e0s.Expected)
451
- expect(error02).toBeInstanceOf(Error0)
452
- expect(error02.expected).toBe(true)
453
- expect(toJSON(error02)).toMatchInlineSnapshot(`
454
- {
455
- "__I_AM_ERROR_0": true,
456
- "anyMessage": undefined,
457
- "cause": undefined,
458
- "clientMessage": undefined,
459
- "code": undefined,
460
- "expected": true,
461
- "httpStatus": undefined,
462
- "message": "nested error 1",
463
- "meta": {
464
- "tagPrefix": "nested",
465
- },
466
- "stack":
467
- "Error0: nested error 1
653
+ expect(fixStack(mergedStack2)).toMatchInlineSnapshot(`
654
+ "Error0: test2
655
+ at <anonymous> (...)
656
+ Error0: test1
468
657
  at <anonymous> (...)"
469
- ,
470
- "tag": "nested:nested",
471
- }
472
658
  `)
473
659
  })
474
660
 
475
- it('cause zod error', () => {
476
- const zodError = z.object({ x: z.number() }).safeParse('test').error
477
- if (!zodError) {
478
- throw new Error('zodError is undefined')
661
+ it('stress: resolve/serialize/flow stays within perf budget', () => {
662
+ const AppError = Error0.use(statusPlugin).use(codePlugin)
663
+
664
+ let current: InstanceType<typeof AppError> = new AppError('root', {
665
+ status: 500,
666
+ code: 'BAD_REQUEST',
667
+ })
668
+ for (let i = 0; i < 300; i += 1) {
669
+ current = new AppError(`level-${i}`, {
670
+ status: 500,
671
+ code: i % 2 === 0 ? 'NOT_FOUND' : 'BAD_REQUEST',
672
+ cause: current,
673
+ })
479
674
  }
480
- expect(zodError).toBeInstanceOf(ZodError)
481
- const error0 = new Error0(zodError)
482
- expect(error0.zodError).toBe(zodError)
483
- expect(error0.message).toBe('Unknown error')
675
+
676
+ let checksum = 0
677
+ const startedAt = performance.now()
678
+ for (let i = 0; i < 3000; i += 1) {
679
+ const resolved = AppError.resolve(current)
680
+ const serialized = AppError.serialize(current, false)
681
+ const flow = current.flow('status')
682
+ checksum += resolved.status ?? 0
683
+ checksum += (serialized.status as number | undefined) ?? 0
684
+ checksum += flow.length
685
+ }
686
+ const elapsedMs = performance.now() - startedAt
687
+ const budgetMs = process.env.CI ? 8000 : 4000
688
+
689
+ expect(checksum).toBeGreaterThan(0)
690
+ expect(elapsedMs).toBeLessThan(budgetMs)
484
691
  })
485
692
 
486
- it('from zod error', () => {
487
- const zodError = z.object({ x: z.number() }).safeParse('test').error
488
- if (!zodError) {
489
- throw new Error('zodError is undefined')
693
+ it('class can be created extended from Error0', () => {
694
+ class MyError extends Error0.use(statusPlugin).use(codePlugin) {
695
+ isStatusAndCode(status: number, code: string): boolean {
696
+ return this.status === status && this.code === code
697
+ }
490
698
  }
491
- expect(zodError).toBeInstanceOf(ZodError)
492
- const error0 = Error0.from(zodError)
493
- expect(error0.zodError).toBe(zodError)
494
- expect(error0.message).toMatchInlineSnapshot(`
495
- "Zod Validation Error: [
496
- {
497
- "expected": "object",
498
- "code": "invalid_type",
499
- "path": [],
500
- "message": "Invalid input: expected object, received string"
501
- }
502
- ]"
503
- `)
699
+ const error = new MyError('test', { status: 400, code: 'NOT_FOUND' })
700
+ expect(error.isStatusAndCode(400, 'NOT_FOUND')).toBe(true)
701
+ expect(error.isStatusAndCode(400, 'BAD_REQUEST')).toBe(false)
702
+ expect(error.name).toBe('Error0')
703
+ expectTypeOf<typeof MyError>().toExtend<ClassError0>()
504
704
  })
505
705
 
506
- it('from axios error', async () => {
507
- function makeFakeAxiosError(): AxiosError {
508
- return {
509
- isAxiosError: true,
510
- name: 'AxiosError',
511
- message: 'Request failed with status code 400',
512
- config: {}, // can be empty for test
513
- toJSON: () => ({}),
514
- response: {
515
- status: 400,
516
- statusText: 'Bad Request',
517
- headers: {},
518
- config: {},
519
- data: {
520
- error: 'Invalid input',
521
- details: ['Field X is required'],
522
- },
523
- },
524
- } as AxiosError
706
+ it('Error0 assignable to LikeError0', () => {
707
+ class MyError extends Error {
708
+ status?: number
709
+ code?: string
710
+ constructor(message: string, options: { cause?: unknown; status?: number; code?: string }) {
711
+ super(message, { cause: options.cause })
712
+ this.status = options.status
713
+ this.code = options.code
714
+ }
715
+
716
+ static from(error: unknown): MyError {
717
+ if (error instanceof MyError) {
718
+ return error
719
+ }
720
+ const object = typeof error === 'object' && error !== null ? (error as Record<string, unknown>) : {}
721
+ const message =
722
+ typeof object.message === 'string' ? object.message : typeof error === 'string' ? error : 'Unknown error'
723
+ const status = typeof object.status === 'number' ? object.status : undefined
724
+ const code = typeof object.code === 'string' ? object.code : undefined
725
+ return new MyError(message, {
726
+ cause: error,
727
+ status,
728
+ code,
729
+ })
730
+ }
731
+
732
+ static serialize(error: MyError): Record<string, unknown> {
733
+ return {
734
+ message: error.message,
735
+ status: error.status,
736
+ code: error.code,
737
+ }
738
+ }
525
739
  }
526
- const axiosError = makeFakeAxiosError()
527
- if (!axiosError) {
528
- throw new Error('axiosError is undefined')
740
+
741
+ type LikeError0<TError> = {
742
+ new (message: string, options: { cause?: unknown; status?: number; code?: string }): TError
743
+ from: (error: unknown) => TError
744
+ serialize: (error: TError) => Record<string, unknown>
529
745
  }
530
- expect(isAxiosError(axiosError)).toBe(true)
531
- const error0 = Error0.from(axiosError)
532
- expect(error0.axiosError).toBe(axiosError)
533
- expect(error0.message).toBe('Axios Error')
534
- expect(error0.meta).toMatchInlineSnapshot(`
535
- {
536
- "axiosData": "{"error":"Invalid input","details":["Field X is required"]}",
537
- "axiosStatus": 400,
538
- }
539
- `)
746
+ expectTypeOf<typeof MyError>().toExtend<LikeError0<MyError>>()
747
+ expectTypeOf<typeof Error0>().toExtend<typeof MyError>()
540
748
  })
749
+
750
+ // we will have no variants
751
+ // becouse you can thorw any errorm and when you do AppError.from(yourError)
752
+ // can use adapt to assign desired props to error, it is enough for transport
753
+ // you even can create computed or method to retrieve your error, so no problems with variants
754
+
755
+ // it('can create and recongnize variant', () => {
756
+ // const AppError = Error0.use(statusPlugin)
757
+ // .use(codePlugin)
758
+ // .use('prop', 'userId', {
759
+ // input: (value: string) => value,
760
+ // output: (error) => {
761
+ // for (const value of error.flow('userId')) {
762
+ // if (typeof value === 'string') {
763
+ // return value
764
+ // }
765
+ // }
766
+ // return undefined
767
+ // },
768
+ // serialize: (value) => value,
769
+ // })
770
+ // const UserError = AppError.variant('UserError', {
771
+ // userId: true,
772
+ // })
773
+ // const error = new UserError('test', { userId: '123', status: 400 })
774
+ // expect(error).toBeInstanceOf(UserError)
775
+ // expect(error).toBeInstanceOf(AppError)
776
+ // expect(error).toBeInstanceOf(Error0)
777
+ // expect(error).toBeInstanceOf(Error)
778
+ // expect(error.userId).toBe('123')
779
+ // expect(error.status).toBe(400)
780
+ // expect(error.code).toBe(undefined)
781
+ // expectTypeOf<typeof error.userId>().toEqualTypeOf<string>()
782
+ // // @ts-expect-error
783
+ // new UserError('test')
784
+ // })
541
785
  })