Skip to content

Commit 216d162

Browse files
fix: use instanceof instead of constructor name in formatErrors (#16089)
## What Fixes `formatErrors` silently dropping the `data` field from `APIError` responses in production builds. Closes #16050 ## Why `formatErrors` used `proto.constructor.name` string comparison to identify `APIError` and `ValidationError` instances. In production, webpack/terser minifies class names (e.g., `APIError` → `t`), causing the check to fail. This silently drops `error.data`, breaking any feature that relies on it — for example, 2FA flows that check `data.requires2FA`. The `APIErrorName`/`ValidationErrorName` module variables were designed to handle this via dynamic reassignment in constructors, but minifiers can inline the initial string value at import sites, defeating the mechanism. ## Changes - Replaced `proto.constructor.name === APIErrorName` / `ValidationErrorName` checks with `instanceof APIError` / `instanceof ValidationError` - For the Mongoose `ValidationError` fallback (which isn't a Payload class), switched to checking `incoming.name === 'ValidationError'` — a string property that Mongoose sets explicitly and isn't affected by minification - Removed unused `APIErrorName` / `ValidationErrorName` imports ## Why instanceof is safe here The original code avoided `instanceof` citing [TypeScript#13965](microsoft/TypeScript#13965), where `instanceof` fails for classes extending `Error` when transpiled to ES5. However: - Payload's `tsconfig` targets **ES2022**, so native class syntax is preserved - `instanceof` works correctly with ES2015+ classes extending `Error` - The original workaround itself was broken by minification — a worse failure mode ## Testing - `npx tsc --noEmit` passes with no errors - Verified that Payload's `ValidationError` (which has `data`) is caught by the `instanceof` branch and never falls through to the Mongoose branch - The Mongoose `ValidationError` check uses the `name` property which Mongoose sets explicitly (`this.name = 'ValidationError'`) — not affected by minification --------- Co-authored-by: Paul Popus <paul@payloadcms.com>
1 parent 5afcef5 commit 216d162

5 files changed

Lines changed: 100 additions & 27 deletions

File tree

packages/payload/src/errors/APIError.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { status as httpStatus } from 'http-status'
22

3-
// This gets dynamically reassigned during compilation
4-
export let APIErrorName = 'APIError'
3+
/** @deprecated Use `instanceof APIError` instead of name comparison. */
4+
export const APIErrorName = 'APIError'
55

66
class ExtendableError<TData extends object = { [key: string]: unknown }> extends Error {
77
data: TData
@@ -17,7 +17,6 @@ class ExtendableError<TData extends object = { [key: string]: unknown }> extends
1717
// show data in cause
1818
cause: data,
1919
})
20-
APIErrorName = this.constructor.name
2120
this.name = this.constructor.name
2221
this.message = message
2322
this.status = status

packages/payload/src/errors/ValidationError.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import type { PayloadRequest } from '../types/index.js'
88

99
import { APIError } from './APIError.js'
1010

11-
// This gets dynamically reassigned during compilation
12-
export let ValidationErrorName = 'ValidationError'
11+
/** @deprecated Use `instanceof ValidationError` instead of name comparison. */
12+
export const ValidationErrorName = 'ValidationError'
1313

1414
export type ValidationFieldError = {
1515
label?: LabelFunction | StaticLabel
@@ -75,7 +75,5 @@ export class ValidationError extends APIError<{
7575
httpStatus.BAD_REQUEST,
7676
results,
7777
)
78-
79-
ValidationErrorName = this.constructor.name
8078
}
8179
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { APIError } from '../errors/APIError.js'
4+
import { ValidationError } from '../errors/ValidationError.js'
5+
import { formatErrors } from './formatErrors.js'
6+
7+
describe('formatErrors', () => {
8+
it('should format a Payload ValidationError', () => {
9+
const err = new ValidationError({
10+
errors: [{ message: 'Field is required', path: 'title' }],
11+
})
12+
13+
const result = formatErrors(err)
14+
15+
expect(result.errors).toHaveLength(1)
16+
expect(result.errors[0]).toMatchObject({
17+
name: 'ValidationError',
18+
message: expect.stringContaining('title'),
19+
data: { errors: [{ message: 'Field is required', path: 'title' }] },
20+
})
21+
})
22+
23+
it('should format a Payload APIError', () => {
24+
const err = new APIError('Something went wrong', 400, { detail: 'bad input' }, true)
25+
26+
const result = formatErrors(err)
27+
28+
expect(result.errors).toHaveLength(1)
29+
expect(result.errors[0]).toMatchObject({
30+
name: 'APIError',
31+
message: 'Something went wrong',
32+
data: { detail: 'bad input' },
33+
})
34+
})
35+
36+
it('should format a Mongoose-style ValidationError', () => {
37+
const mongooseError = {
38+
name: 'ValidationError',
39+
errors: {
40+
email: { path: 'email', message: 'is invalid' },
41+
},
42+
}
43+
44+
const result = formatErrors(mongooseError as any)
45+
46+
expect(result.errors).toHaveLength(1)
47+
expect(result.errors[0]).toMatchObject({ field: 'email', message: 'is invalid' })
48+
})
49+
50+
it('should format an array message error', () => {
51+
const err = { message: [{ message: 'item one' }, { message: 'item two' }] }
52+
53+
const result = formatErrors(err as any)
54+
55+
expect(result.errors).toHaveLength(2)
56+
expect(result.errors[0]).toMatchObject({ message: 'item one' })
57+
expect(result.errors[1]).toMatchObject({ message: 'item two' })
58+
})
59+
60+
it('should format a named non-Payload error', () => {
61+
const err = new Error('Unexpected failure')
62+
63+
const result = formatErrors(err as any)
64+
65+
expect(result.errors).toHaveLength(1)
66+
expect(result.errors[0]!.message).toBe('Unexpected failure')
67+
})
68+
69+
it('should format a Payload APIError with no data', () => {
70+
const err = new APIError('Server error')
71+
72+
const result = formatErrors(err)
73+
74+
expect(result.errors).toHaveLength(1)
75+
expect(result.errors[0]).toStrictEqual({ message: 'Server error' })
76+
})
77+
78+
it('should return unknown error for null/undefined input', () => {
79+
expect(formatErrors(null as any).errors[0]!.message).toBe('An unknown error occurred.')
80+
expect(formatErrors(undefined as any).errors[0]!.message).toBe('An unknown error occurred.')
81+
})
82+
})

packages/payload/src/utilities/formatErrors.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,30 @@
11
import type { ErrorResult } from '../config/types.js'
2-
import type { APIError } from '../errors/APIError.js'
32

4-
import { APIErrorName } from '../errors/APIError.js'
5-
import { ValidationErrorName } from '../errors/ValidationError.js'
3+
import { APIError } from '../errors/APIError.js'
4+
import { ValidationError } from '../errors/ValidationError.js'
65

76
export const formatErrors = (incoming: { [key: string]: unknown } | APIError): ErrorResult => {
87
if (incoming) {
9-
// Cannot use `instanceof` to check error type: https://github.com/microsoft/TypeScript/issues/13965
10-
// Instead, get the prototype of the incoming error and check its constructor name
11-
const proto = Object.getPrototypeOf(incoming)
12-
138
// Payload 'ValidationError' and 'APIError'
14-
if (
15-
(proto.constructor.name === ValidationErrorName || proto.constructor.name === APIErrorName) &&
16-
incoming.data
17-
) {
9+
if ((incoming instanceof ValidationError || incoming instanceof APIError) && incoming.data) {
1810
return {
1911
errors: [
2012
{
21-
name: incoming.name as string,
13+
name: incoming.name,
2214
data: incoming.data as Record<string, unknown>,
23-
message: incoming.message as string,
15+
message: incoming.message,
2416
},
2517
],
2618
}
2719
}
2820

2921
// Mongoose 'ValidationError': https://mongoosejs.com/docs/api/error.html#Error.ValidationError
30-
if (proto.constructor.name === ValidationErrorName && 'errors' in incoming && incoming.errors) {
22+
if (
23+
'name' in incoming &&
24+
incoming.name === 'ValidationError' &&
25+
'errors' in incoming &&
26+
incoming.errors
27+
) {
3128
return {
3229
errors: Object.keys(incoming.errors).reduce(
3330
(acc, key) => {

packages/plugin-nested-docs/src/hooks/resaveChildren.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { CollectionAfterChangeHook, JsonObject, ValidationError } from 'payload'
1+
import type { CollectionAfterChangeHook, JsonObject } from 'payload'
22

3-
import { APIError, ValidationErrorName } from 'payload'
3+
import { APIError, ValidationError } from 'payload'
44

55
import type { NestedDocsPluginConfig } from '../types.js'
66

@@ -91,10 +91,7 @@ export const resaveChildren =
9191
)
9292
req.payload.logger.error(err)
9393

94-
if (
95-
(err as ValidationError)?.name === ValidationErrorName &&
96-
(err as ValidationError)?.data?.errors?.length
97-
) {
94+
if (err instanceof ValidationError && err.data?.errors?.length) {
9895
throw new APIError(
9996
'Could not publish or save changes: One or more children are invalid.',
10097
400,

0 commit comments

Comments
 (0)