NestJS 12.2: Why validationError.target Is Now undefined
NestJS 12.2 defaults validationError.target to false, so a custom exceptionFactory reading error.target now gets undefined. The fix, plus a cheat sheet.

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.
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
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.
- 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
Here's the pipe registration behind the audit log in the intro, unchanged across the upgrade:
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: 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.
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:
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.
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:
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.
When the full object really is the requirement, say so explicitly instead of relying on an implicit default that already changed once:
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.
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:
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.
- 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.
- 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.
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.
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.
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.
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.
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.
| 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 |
@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.
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.
Runs right in your browser — poke at it and watch the concept react live.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
- 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___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.