Skip to content

Bug: formatErrors drops data field from APIError in production builds due to class name minification #16050

@tsemachh

Description

@tsemachh

Describe the Bug

Summary

In production builds (Next.js 16 with webpack minification), formatErrors.ts silently drops the data field from APIError responses. This causes any feature that relies on error.data in the client (e.g., 2FA flows that check data.requires2FA) to break in production while working perfectly in development.

Affected File

packages/payload/src/utilities/formatErrors.ts

Root Cause

formatErrors.ts uses proto.constructor.name string comparison to identify APIError and ValidationError instances:

// Cannot use instanceof to check error type
// https://github.com/microsoft/TypeScript/issues/13965
// Instead, get the prototype of the incoming error and check its constructor name
const proto = Object.getPrototypeOf(incoming)
if (
  (proto.constructor.name === ValidationErrorName ||
    proto.constructor.name === APIErrorName) &&
  incoming.data
) {
  return { errors: [{ name: incoming.name, data: incoming.data, message: incoming.message }] }
}
  • In development: class names are preserved → proto.constructor.name === 'APIError' is truedata is included
  • In production (minified build): webpack/terser mangles class names → APIError becomes t or e → the check is false → falls through to the generic branch which only returns messagedata is silently dropped

Why instanceof Would Actually Work Here

The comment references TypeScript issue #13965, where instanceof fails for classes extending Error with target: ES5. However, APIError already applies the standard fix for this — it calls Object.setPrototypeOf(this, new.target.prototype) in its constructor via ExtendableError:

// node_modules/payload/dist/errors/APIError.js
class ExtendableError extends Error {
  constructor(message) {
    super(message)
    this.name = this.constructor.name
    if (typeof Error.captureStackTrace === 'function') {
      Error.captureStackTrace(this, this.constructor)
    } else {
      this.stack = new Error(message).stack
    }
    Object.setPrototypeOf(this, new.target.prototype) // this fixes instanceof
  }
}

This means instanceof APIError works correctly and would be the safe fix.

Reproduction — Diagnostic Endpoint

Add this route to a Next.js Payload project and call it in both dev and production:

// src/app/api/test-2fa-error/route.ts
import { NextResponse } from 'next/server'
import { APIError } from 'payload'

export async function GET() {
  const err = new APIError('test error', 428, { requires2FA: true })
  const proto = Object.getPrototypeOf(err)
  const constructorName = proto.constructor.name
  const constructorNameMatch = constructorName === 'APIError' // Replicate the exact check from formatErrors.ts

  let formattedData: any = null
  if (constructorNameMatch && err.data) {
    formattedData = err.data // data is preserved
  } else {
    // data is dropped — the bug
  }

  return NextResponse.json({
    pass: constructorNameMatch,
    constructorName,            // 'APIError' in dev, 't' or 'e' in prod
    constructorNameMatch,       // true in dev, false in prod
    instanceofWorks: err instanceof APIError,  // true in BOTH
    dataPreserved: formattedData !== null,
  })
}

Dev response:

{ "pass": true, "constructorName": "APIError", "constructorNameMatch": true, "instanceofWorks": true, "dataPreserved": true }

Production response (minified build):

{ "pass": false, "constructorName": "t", "constructorNameMatch": false, "instanceofWorks": true, "dataPreserved": false }

Note: instanceofWorks is true in both environments, confirming instanceof is the correct fix.

Real-World Impact

In our application, we use a 2FA flow where the beforeLogin hook throws:

throw new APIError('2FA required', 428, { requires2FA: true })

The client checks error.data.requires2FA to show the TOTP input. In production, data is dropped by formatErrors, so the client never sees requires2FA: true and the 2FA input never appears — breaking login for all 2FA-enabled users.

This worked in Next.js 15 (less aggressive minification) and broke after upgrading to Next.js 16 canary, which uses more aggressive class name mangling.

Proposed Fix

Replace the constructor name check with instanceof:

// packages/payload/src/utilities/formatErrors.ts

// BEFORE (broken in production):
const proto = Object.getPrototypeOf(incoming)
if (
  (proto.constructor.name === ValidationErrorName ||
    proto.constructor.name === APIErrorName) &&
  incoming.data
) { ... }

// AFTER (works in all environments):
if (
  (incoming instanceof ValidationError || incoming instanceof APIError) &&
  incoming.data
) { ... }

Since APIError and ValidationError both call Object.setPrototypeOf(this, new.target.prototype) in their constructors, instanceof works correctly even with TypeScript target: ES5 — the original reason for avoiding it no longer applies.

Workaround

Until this is fixed in Payload, the afterError global hook can re-inject the data field:

// payload.config.ts
hooks: {
  afterError: [async ({ error }) => {
    const err = error as any
    if (err?.status === 428 && err?.data?.requires2FA) {
      return { response: { errors: [{ message: err.message, data: err.data }] } }
    }
  }],
}

Environment

  • Payload 3.79.0
  • Next.js 16.2.0-canary.9 (also reproducible with any Next.js version that minifies server-side class names)
  • Node.js 20

Link to the code that reproduces this issue

https://github.com/payloadcms/payload/blob/main/packages/payload/src/utilities/formatErrors.ts

Reproduction Steps

  1. Create a Next.js + Payload project
  2. Add a beforeLogin hook that throws new APIError('2FA required', 428, { requires2FA: true })
  3. Add the diagnostic route from src/app/api/test-2fa-error/route.ts (see description above)
  4. Run in development (next dev) — call /api/test-2fa-error — observe dataPreserved: true
  5. Build for production (next build && next start) — call /api/test-2fa-error — observe dataPreserved: false and constructorName: "t" (mangled)
  6. Attempt login with a 2FA-enabled user in production — the 2FA TOTP input never appears because error.data.requires2FA is undefined

Which area(s) are affected?

area: core

Environment Info

Binaries:
  Node: 23.11.1
  npm: 10.9.2
  Yarn: 1.22.22
  pnpm: 10.16.1
Relevant Packages:
  payload: 3.79.0
  next: 16.2.0-canary.9
  @payloadcms/db-mongodb: 3.79.0
  @payloadcms/email-nodemailer: 3.79.0
  @payloadcms/graphql: 3.79.0
  @payloadcms/next/utilities: 3.79.0
  @payloadcms/payload-cloud: 3.79.0
  @payloadcms/plugin-cloud-storage: 3.79.0
  @payloadcms/plugin-import-export: 3.79.0
  @payloadcms/richtext-lexical: 3.79.0
  @payloadcms/storage-gcs: 3.79.0
  @payloadcms/translations: 3.79.0
  @payloadcms/ui/shared: 3.79.0
  react: 19.2.4
  react-dom: 19.2.4
Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:07:05 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6020
  Available memory (MB): 32768
  Available CPU cores: 10

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions