A NestJS app upgrades from @nestjs/core 12.1 to 12.2 — npm i @nestjs/core@latest @nestjs/common@latest, nothing else touched. The test suite stays green. In production, an audit log that has recorded every failed signup for months — the field that failed, the message, and the full payload the user actually submitted — starts writing submittedPayload: undefined on every single entry. No exception, no crash, no warning in the logs. The validation itself still works exactly as before: bad input still gets rejected with a 400. Only the forensic trail disappeared.
This is written against @nestjs/core 12.2.0 (verified 2026-10-20 via npm view @nestjs/core dist-tags and the official v12.2.0 release notes; 12.0.0 shipped 2026-08-27, and the 11.x line remains on the legacy npm tag at 11.2.7). The change described here — validationError.target defaulting to false — is listed by name in that release's "new defaults and behaviour changes" section, not inferred from a diff. It applies to the class-validator-based ValidationPipe only; it does not touch the newer StandardSchemaValidationPipe covered in an earlier episode of this series.
What you'll learn
By the end of this article you'll be able to:
- Explain exactly what
target,value, andpropertyeach mean on a class-validatorValidationError, and which of them NestJS's own built-inexceptionFactoryactually uses - State precisely why
@nestjs/core12.2 changedvalidationError.target's default tofalse, in the maintainers' own words - Fix a custom
exceptionFactorythat silently breaks on the upgrade, without just disabling the new default - Restore the pre-12.2 behavior deliberately, globally or per pipe, when you genuinely need it
- Place this change correctly on the request lifecycle's pipes stage, and rule out the two other places it could be confused for a bug
Who this is for
You've registered a ValidationPipe — global or scoped — against class-validator DTOs at least once, and ideally you've written or read a custom exceptionFactory. If you haven't read this series' NestJS request lifecycle episode, it maps exactly where a pipe runs relative to guards, interceptors, and filters — useful background, but this article stands on its own.
Table of contents
- The problem: an exceptionFactory that used to work
- The mental model: target, value, and property are three different things
- Stage 1: why 12.2 changed the default
- Stage 2: fixing the broken exceptionFactory
- Stage 3: restoring the old behavior on purpose
- Stage 4: the quieter 12.2 changes riding along
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
The problem: an exceptionFactory that used to work
Here's the pipe registration behind the audit log in the intro, unchanged across the upgrade:
// main.ts app.useGlobalPipes( new ValidationPipe({ exceptionFactory: (errors) => { const first = errors[0]; auditLog.record({ field: first.property, message: Object.values(first.constraints ?? {})[0], // this line is what broke — quietly submittedPayload: first.target, }); return new BadRequestException(errors); }, }), ); Before 12.2, first.target was the whole object class-validator had just validated — the full DTO instance built from the request body, every field included, whether it passed or failed. That made it an easy, if slightly lazy, way to log "here's exactly what the user sent" next to the one field that failed. After 12.2, with nothing else in the application touched, first.target is undefined. submittedPayload: undefined is syntactically valid, so nothing throws — the audit log entry is just worse than it was.
The mental model: target, value, and property are three different things
The mental model: a class-validator ValidationError is a small record for one failing property, and it can carry up to four separate pieces of information about that failure — and only one of them changed in 12.2:
-
property— the name of the field that failed, e.g."email". Always present. Unaffected by this change. -
constraints— a map of rule name to human-readable message, e.g.{ isEmail: "email must be an email" }. Always present. Unaffected. -
value— the actual value that was submitted for that one field, e.g."not-an-email". This is scoped to the failing property alone. Unaffected by the 12.2 change. -
target— not the field's value, but the entire object being validated, with every property, whether it passed or failed. This is the one@nestjs/core12.2 now suppresses by default.
That distinction is the whole article: value tells you what was wrong with the one field that failed; target repeats the complete request body on every single error in the array. A DTO with three failing fields used to produce three ValidationError objects, each one carrying a full copy of the same object. NestJS's own source comment on the change states the reasoning plainly: "the target of an error is the whole object it was found in, so an exception factory that sends the errors would repeat that object." Repeating it isn't just noisy — it's also how a request body with a password field, a token, or any other sensitive value ends up duplicated into every validation error a naive exceptionFactory forwards to a client or a log.
Stage 1: why 12.2 changed the default
Before @nestjs/core 12.2, ValidationPipe didn't configure validationError for you at all — it passed your validatorOptions straight through to class-validator's validate(), and class-validator's own long-standing default includes target. Since 12.2, ValidationPipe.transform() builds that option itself, and your own setting — if you have one — wins:
// @nestjs/common's ValidationPipe, from 12.2.0 onward (simplified) const errors = await validate(object, { ...this.validatorOptions, validationError: { target: false, // the new default ...this.validatorOptions.validationError, // your setting, if any, overrides it }, }); Key concept: this is a default you can always override, not a removed feature. If you never touch validationError, you now get target: false. If you explicitly configure validationError: { target: true } on your ValidationPipe, that object sits on top of the spread above and wins, on every version from 12.2 onward.
Stage 2: fixing the broken exceptionFactory
The honest fix usually isn't restoring target — it's noticing that target was the wrong tool for the job. The audit log wanted "what did the user submit," and that's exactly what value already gives you, scoped to the field that actually failed, with no risk of dragging unrelated fields (passwords, tokens) along for the ride:
app.useGlobalPipes( new ValidationPipe({ exceptionFactory: (errors) => { const first = errors[0]; auditLog.record({ field: first.property, message: Object.values(first.constraints ?? {})[0], submittedValue: first.value, // scoped to this field — works on every 12.x }); return new BadRequestException(errors); }, }), ); Key concept: value was never touched by the 12.2 change, on any version. If your exceptionFactory only needs to know what the failing field contained, switching from target to value fixes the bug today and keeps working on whatever the default does next.
If you genuinely need the whole object — reconstructing the full attempted request for a support ticket, say — read on.
Stage 3: restoring the old behavior on purpose
When the full object really is the requirement, say so explicitly instead of relying on an implicit default that already changed once:
app.useGlobalPipes( new ValidationPipe({ validationError: { target: true }, // explicit — survives future default changes exceptionFactory: (errors) => { const first = errors[0]; auditLog.record({ field: first.property, message: Object.values(first.constraints ?? {})[0], submittedPayload: first.target, // present again, on purpose }); return new BadRequestException(errors); }, }), ); Key concept: this is scoped to wherever you construct that particular ValidationPipe. A global pipe with validationError: { target: true } restores the old behavior app-wide; a route-scoped @UsePipes(new ValidationPipe({ validationError: { target: true } })) restores it only there, leaving the global pipe on the new, leaner default.
Stage 4: the quieter 12.2 changes riding along
target isn't the only ValidationPipe default the same release changed. Two more are worth knowing before you assume a 400 you're suddenly seeing is a bug in your code:
new ValidationPipe({ // New in 12.2 — caps how long the combined message string can get. // Past this, extra messages are replaced with one "omitted" entry. maxErrorMessagesLength: 1_000_000, // this is already the default }); The other: sending an array or a bare primitive where a validated class is expected now gets a 400 instead of silently passing an unvalidated value through. If a route's DTO is CreateUserDto and the body arrives as [1, 2, 3] or the string "oops", 12.2 rejects it at the pipe instead of letting it reach the handler shaped nothing like the DTO. Both changes ship in the same release and share the same spirit as the target default: stop assuming more than class-validator was actually asked to check.
Edge cases and gotchas
- The built-in
exceptionFactorywas never affected.@nestjs/common's own default error formatter — the oneValidationPipeuses when you don't pass your own — only ever readspropertyandconstraintsto build its message array. If you've never written a customexceptionFactory, this entire change is invisible to you. - Nested DTOs still carry
children, not a nestedtarget. A failure onaddress.zipproduces aValidationErrorwithchildren: [{ property: 'address', children: [{ property: 'zip', ... }] }];targetsuppression applies at every level of that tree the same way. - This is specific to class-validator's
ValidationPipe.StandardSchemaValidationPipe(covered in an earlier episode) validates with a Standard Schema library's own~standard.validate(), which has notargetconcept at all — nothing here applies to it. - A pipe constructed before you set the option doesn't retroactively pick it up.
validationErroris read once, in the constructor's resolved options — set it when you callnew ValidationPipe({ ... }), not after. - Logging
error.targetto the console for local debugging is still fine. The change doesn't remove the data from the validation run itself — it only removes it from what reachesexceptionFactoryby default.console.logstatements inside your own services that build the object from the raw request body are unaffected either way.
Best practices
- Default to
error.value, noterror.target, in anyexceptionFactoryyou write. It's scoped to the one field that failed, was never affected by this change, and can't accidentally leak an unrelated sensitive field. - Never echo the full submitted payload back to the client in an error response, with or without this default — a validation error is exactly the response an attacker can use to fingerprint which fields your DTO expects.
- Set
validationError: { target: true }explicitly, and only, where you've confirmed you need it — an internal audit log or a support tool, never a response body a client sees. - Audit every custom
exceptionFactoryin the codebase before upgrading to 12.2, the same way you'd check for any other breaking default:grep -rn "exceptionFactory" src/and look for.targeton the result. - Pin the behavior you rely on, not the default. The
target: truedefault existed for years and still changed once; writing it explicitly means the next default change can't surprise you again.
FAQ
Does this break validation itself, or just error reporting?
Just error reporting. Validation still runs exactly the same way, and the HTTP response code and the fact that invalid requests get rejected are unchanged. Only what a custom exceptionFactory can read off each error changed.
Will my app silently stop validating anything after this upgrade?
No. target being undefined has nothing to do with whether validation ran — it only affects what one specific field on the resulting error object contains.
Does error.value ever get suppressed the same way?
No. The 12.2 release notes and the pipe's own source only change the target default; value — the specific field's submitted value — is untouched on every 12.x version.
Is this in the official migration guide, or just the release notes?
It's listed in the v12.2.0 release notes under that release's own "new defaults and behaviour changes," since it's a minor-version default change rather than a major-version breaking change requiring a migration guide entry.
Can I set validationError.target per route instead of globally?
Yes — pass the option to a ValidationPipe instance scoped with @UsePipes() on a controller or a single handler, the same way any other ValidationPipe option can be scoped.
Cheat sheet
| Task | Code | Notes |
|---|---|---|
| Check if your code is affected | grep -rn "exceptionFactory" src/ | Only custom factories reading .target are affected |
| Fix forward (recommended) | Read error.value instead of error.target | Scoped to the failing field; unaffected by this change |
| Restore old behavior, globally | new ValidationPipe({ validationError: { target: true } }) | Register once, in main.ts |
| Restore old behavior, one route | @UsePipes(new ValidationPipe({ validationError: { target: true } })) | Leaves the global pipe on the new default |
| Confirm the built-in factory is unaffected | Don't pass exceptionFactory at all | The default formatter only ever used property + constraints |
target vs value | target = whole submitted object · value = one field's value | Only target changed its default in 12.2 |
Other 12.2 ValidationPipe defaults | maxErrorMessagesLength (new), array/primitive → 400 (new) | Same release, same "stop assuming more than was checked" theme |
// main.ts — explicit, upgrade-proof configuration app.useGlobalPipes( new ValidationPipe({ // Only set this if something genuinely needs the whole submitted object. // validationError: { target: true }, exceptionFactory: (errors) => { const first = errors[0]; return new BadRequestException({ field: first.property, message: Object.values(first.constraints ?? {})[0], submittedValue: first.value, // not first.target }); }, }), ); Key takeaways
-
@nestjs/core12.2 defaultsvalidationError.targettofalseonValidationPipe— a deliberate change, documented in the v12.2.0 release notes, not a regression. -
targetis the entire validated object;valueis the one failing field's value. Onlytarget's default changed;valuenever moves. - NestJS's own built-in
exceptionFactorynever readtarget— this only bites a hand-written one, which is exactly why it fails silently instead of at boot. - Fix forward by reading
error.value, or restore the old behavior deliberately withvalidationError: { target: true }, globally or per route. - This lives entirely in the pipes stage of the request lifecycle — guards, interceptors, and filters are untouched by it.
Ending
The bug in the intro was never really about NestJS removing something — it was about a default quietly doing less work on an app's behalf than it used to, in a way that compiles, ships, and only shows up as a slightly worse log entry months later. That's the same shape of risk every implicit default carries: it's free until the day it changes, and the fix is almost always the same one — write down what you actually need (error.value, not "whatever target happens to contain") instead of leaning on whatever the framework currently hands you.
Has a NestJS minor-version upgrade ever changed behavior you didn't know you were depending on? Drop it in the comments.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
📚 Read next
- NestJS 12: Standard Schema Validation Without class-validator
- NestJS Guards: CanActivate, ExecutionContext & Reflector
- NestJS Request Lifecycle Explained (with Cheat Sheet)
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
