Visitar URL original
feat(errors): Recognize Feathers errors across duplicate module copies · feathersjs/feathers@d737b50 · GitHub
Skip to content

Commit d737b50

Browse files
fratzingerclaude
andcommitted
feat(errors): Recognize Feathers errors across duplicate module copies
`instanceof FeathersError` fails when multiple versions of feathers are installed or CommonJS and ES modules are mixed. Feathers errors are now branded with a global `Symbol.for('feathers.error')` and the error classes implement `Symbol.hasInstance`, so `instanceof` works across module copies while still narrowing to the specific error type. Also adds a typed `isFeathersError(error, type?)` guard that accepts an error name or status code, and adds the missing `Gone` entries to `errors` and the `Errors` interface. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0d96127 commit d737b50

4 files changed

Lines changed: 146 additions & 3 deletions

File tree

‎packages/feathers/src/errors.test.ts‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it } from 'vitest'
1+
import { describe, it, vi } from 'vitest'
22
import assert from 'assert'
33
import * as errors from '../src/errors.js'
44

@@ -203,6 +203,55 @@ describe('@feathersjs/errors', () => {
203203
assert.ok(error.constructor === errors.NotFound)
204204
assert.ok(error.constructor.name === 'NotFound')
205205
})
206+
207+
it('does not match custom subclasses by name', () => {
208+
class CustomError extends errors.BadRequest {}
209+
210+
assert.ok(new CustomError() instanceof errors.BadRequest)
211+
assert.ok(!(new errors.BadRequest() instanceof CustomError))
212+
})
213+
})
214+
215+
describe('isFeathersError', () => {
216+
it('checks for any Feathers error', () => {
217+
assert.ok(errors.isFeathersError(new errors.NotFound()))
218+
assert.ok(!errors.isFeathersError(new Error()))
219+
assert.ok(!errors.isFeathersError({ ...new errors.NotFound(), type: 'FeathersError' }))
220+
assert.ok(!errors.isFeathersError(null))
221+
assert.ok(!errors.isFeathersError('NotFound'))
222+
})
223+
224+
it('checks for a specific error by name or code', () => {
225+
const error: unknown = new errors.NotFound()
226+
227+
assert.ok(errors.isFeathersError(error, 'NotFound'))
228+
assert.ok(errors.isFeathersError(error, 404))
229+
assert.ok(!errors.isFeathersError(error, 'BadRequest'))
230+
assert.ok(!errors.isFeathersError(error, 400))
231+
assert.ok(!errors.isFeathersError(error, 'toString' as any))
232+
233+
if (errors.isFeathersError(error, 404)) {
234+
const notFound: errors.NotFound = error
235+
assert.strictEqual(notFound.code, 404)
236+
}
237+
})
238+
239+
it('recognizes errors from a duplicate module instance', async () => {
240+
vi.resetModules()
241+
const duplicate: typeof errors = await import('../src/errors.js')
242+
243+
assert.notStrictEqual(duplicate.FeathersError, errors.FeathersError)
244+
245+
const error = new duplicate.NotFound('Not here')
246+
247+
assert.ok(error instanceof errors.FeathersError)
248+
assert.ok(error instanceof errors.NotFound)
249+
assert.ok(!(error instanceof errors.BadRequest))
250+
assert.ok(errors.isFeathersError(error, 'NotFound'))
251+
assert.ok(errors.isFeathersError(error, 404))
252+
assert.ok(!errors.isFeathersError(error, 'BadRequest'))
253+
assert.ok(new errors.BadRequest() instanceof duplicate.BadRequest)
254+
})
206255
})
207256

208257
describe('successful error creation', () => {

‎packages/feathers/src/errors.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,37 @@ interface ErrorProperties extends Omit<FeathersErrorJSON, 'message'> {
1414
type: string
1515
}
1616

17+
// Registered globally so that errors are recognized across duplicate package
18+
// versions, CJS/ESM builds and realms where a plain `instanceof` would fail
19+
const FEATHERS_ERROR = Symbol.for('feathers.error')
20+
21+
const hasPrototypeInstance = Function.prototype[Symbol.hasInstance]
22+
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)
23+
1724
export class FeathersError extends Error {
1825
readonly type: string
1926
readonly code: number
2027
readonly className: string
2128
readonly data: any
2229
readonly errors: any
2330

31+
static [Symbol.hasInstance]<T>(this: abstract new (...args: any[]) => T, value: unknown): value is T {
32+
if (hasPrototypeInstance.call(this, value)) {
33+
return true
34+
}
35+
36+
const name: unknown =
37+
value !== null && typeof value === 'object' ? (value as any)[FEATHERS_ERROR] : undefined
38+
39+
if (typeof name !== 'string') {
40+
return false
41+
}
42+
43+
const ErrorClass = this as unknown
44+
45+
return ErrorClass === FeathersError || (hasOwn(errorsByName, name) && errorsByName[name] === ErrorClass)
46+
}
47+
2448
constructor(err: ErrorMessage, name: string, code: number, className: string, _data: any) {
2549
let msg = typeof err === 'string' ? err : 'Error'
2650
const properties: ErrorProperties = {
@@ -42,6 +66,8 @@ export class FeathersError extends Error {
4266

4367
super(msg)
4468
Object.assign(this, properties)
69+
// Non-enumerable so that it is not copied by spreading or `Object.assign`
70+
Object.defineProperty(this, FEATHERS_ERROR, { value: name })
4571
}
4672

4773
toJSON() {
@@ -193,6 +219,7 @@ export interface Errors {
193219
NotAcceptable: NotAcceptable
194220
Timeout: Timeout
195221
Conflict: Conflict
222+
Gone: Gone
196223
LengthRequired: LengthRequired
197224
Unprocessable: Unprocessable
198225
TooManyRequests: TooManyRequests
@@ -209,6 +236,7 @@ export interface Errors {
209236
406: NotAcceptable
210237
408: Timeout
211238
409: Conflict
239+
410: Gone
212240
411: LengthRequired
213241
422: Unprocessable
214242
429: TooManyRequests
@@ -218,6 +246,26 @@ export interface Errors {
218246
503: Unavailable
219247
}
220248

249+
const errorsByName: Record<string, abstract new (...args: any[]) => FeathersError> = {
250+
BadRequest,
251+
NotAuthenticated,
252+
PaymentError,
253+
Forbidden,
254+
NotFound,
255+
MethodNotAllowed,
256+
NotAcceptable,
257+
Timeout,
258+
Conflict,
259+
Gone,
260+
LengthRequired,
261+
Unprocessable,
262+
TooManyRequests,
263+
GeneralError,
264+
NotImplemented,
265+
BadGateway,
266+
Unavailable
267+
}
268+
221269
export const errors = {
222270
FeathersError,
223271
BadRequest,
@@ -229,6 +277,7 @@ export const errors = {
229277
NotAcceptable,
230278
Timeout,
231279
Conflict,
280+
Gone,
232281
LengthRequired,
233282
Unprocessable,
234283
TooManyRequests,
@@ -255,6 +304,28 @@ export const errors = {
255304
503: Unavailable
256305
}
257306

307+
/**
308+
* Type guard for Feathers errors that also works across duplicate package versions.
309+
* Optionally checks for a specific error by name or status code.
310+
*
311+
* @example
312+
* isFeathersError(error) // error is FeathersError
313+
* isFeathersError(error, 'NotFound') // error is NotFound
314+
* isFeathersError(error, 404) // error is NotFound
315+
*/
316+
export function isFeathersError<K extends keyof Errors = 'FeathersError'>(
317+
error: unknown,
318+
type?: K
319+
): error is Errors[K] {
320+
if (type === undefined) {
321+
return error instanceof FeathersError
322+
}
323+
324+
const ErrorClass = hasOwn(errors, type) ? errors[type as keyof typeof errors] : undefined
325+
326+
return ErrorClass !== undefined && error instanceof ErrorClass
327+
}
328+
258329
export function convert(error: any, status?: number) {
259330
if (!error) {
260331
return error

‎packages/feathers/src/http/middleware.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Params, Service, Query } from '../index.js'
22
import type { HookContext, NextFunction } from '../hooks/index.js'
3-
import { BadRequest, FeathersError } from '../errors.js'
3+
import { BadRequest, isFeathersError } from '../errors.js'
44
import { parse } from '../qs.js'
55

66
interface RouteLookup {
@@ -80,7 +80,7 @@ export function errorHandler() {
8080
await next()
8181
} catch (error: any) {
8282
const errorData = error.toJSON ? error.toJSON() : { message: error.message }
83-
const status = error instanceof FeathersError ? error.code || 500 : 500
83+
const status = isFeathersError(error) ? error.code || 500 : 500
8484
const origin = context.request.headers.get('origin') || '*'
8585

8686
context.result = Response.json(errorData, {

‎website/content/api/errors.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,29 @@ Feathers errors contain the following fields:
7777
To convert a Feathers error back to an object call `error.toJSON()`. A normal `console.log` of a JavaScript Error object will not automatically show those additional properties described above (even though they can be accessed directly).
7878
::
7979

80+
## Checking errors
81+
82+
Use `isFeathersError` to check if a value is a Feathers error. It is a type guard and can optionally check for a specific error by name or status code:
83+
84+
```ts
85+
import { isFeathersError } from 'feathers/errors'
86+
87+
try {
88+
await app.service('messages').get(1)
89+
} catch (error: unknown) {
90+
if (isFeathersError(error, 'NotFound')) {
91+
// error is NotFound
92+
} else if (isFeathersError(error, 403)) {
93+
// error is Forbidden
94+
} else if (isFeathersError(error)) {
95+
// error is FeathersError
96+
console.log(error.code)
97+
}
98+
}
99+
```
100+
101+
`isFeathersError` and `instanceof` (e.g. `error instanceof NotFound`) also recognize errors created by a different copy of `feathers`, for example when multiple versions are installed or CommonJS and ES modules are mixed. Custom errors (see below) are only recognized by `instanceof` within the same copy of the module that defines them.
102+
80103
## Custom errors
81104

82105
You can create custom errors by extending from the `FeathersError` class and calling its constructor with `(message, name, code, className, data)`:

0 commit comments

Comments
 (0)