#17800·nest

ValidationPipe returns 500 instead of 400 for a deeply nested JSON body

Author: xia-chaoCreated Sep 17, 2026Updated Sep 17, 2026

Current behavior

Using ValidationPipe, a request body that is nested deeply enough returns 500 instead of 400.

POST /deep
{"a":{"a":{"a": ... }}}     // 18 KB
body size result
12 KB 201
14 KB 500
18 KB 500

18 KB is well below the Express 100 KB body limit, so nothing rejects it earlier — any client can trigger this.

Removing ValidationPipe makes it work

Same app, same body, the only difference is whether ValidationPipe is applied:

POST /without-pipe   14 KB  ->  201
POST /with-pipe      14 KB  ->  500

Plain express.json() also returns 200 for the same body. So the body itself is fine — ValidationPipe is what turns it into a 500.

What the debugger shows

Step 1: the body is handed to class-transformer as-is

Breakpoint at packages/common/pipes/validation.pipe.ts:155:

The WATCH panel on the left shows the input at that moment:

JSON.stringify(value).length = 18001
metadata.type                = 'body'

The lines above it only do toEmptyIfNil / isPrimitive / stripProtoKeysnone of them looks at nesting depth, so those 18001 bytes go straight into classTransformer.plainToInstance.

class-transformer walks the payload recursively, and ~2.500 levels exhaust the call stack:

RangeError: Maximum call stack size exceeded
  at TransformOperationExecutor.transform (class-transformer/.../TransformOperationExecutor.js:299)

Step 2: the RangeError is not an HttpException, so it becomes a 500

Breakpoint at packages/core/exceptions/base-exception-filter.ts:31:

The local variable holds that exception:

exception = RangeError: Maximum call stack size exceeded

WATCH:

exception.constructor.name         = 'RangeError'
exception instanceof HttpException = false
exception.getStatus                = undefined

Line 31 sees it is not an HttpException and routes it to handleUnknownError()500.

That last step is the problem: an exception thrown by a third-party library because the input was too deeply nested escapes unmodified, and Nest never translates it into HTTP semantics.

Reproduction

@Post('deep')
deep(@Body(new ValidationPipe()) body: Dto) {
  return { ok: true };
}
const depth = 3000;
const body = '{"a":'.repeat(depth) + '1' + '}'.repeat(depth);   // 18001 bytes

await fetch('http://localhost:3000/deep', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body,
});
// returns 500, expected 400

Expected behavior

Input nested deeply enough to exhaust the call stack is a client input problem and should return 400, rather than letting the RangeError surface as a 500.

Related

Nest has fixed the exact same shape before:

  • #17421 fix(express): map missing multer field nesting error — a multipart request nested too deeply made Multer throw LIMIT_FIELD_NESTING, and Nest returned a 500 until the error was mapped on the Nest side
  • #17769 fix(express): map multer errors by code instead of message

The only difference here is that class-transformer is the one throwing.

Environment

@nestjs/core 12.0.3 (also reproduces on master 8ad792aaa), class-transformer 0.5.1, express 5.2.1, Node v26.8.1