nestjs

Activity

Stars and mentions · Last 30 days, Oct 7 – Nov 5

GitHub stars221
Mentions21
0481216Oct 7Oct 21Nov 5
UTC days · repositories nestjs owns4 repositories

Top repositories

nestjs’s repositories ranked by new forks, as on trending.

Loading

Contributes to

Repositories outside nestjs’s account where nestjs opened or reviewed pull requests in the past 365 days.

Who noticed nestjs

Everyone who starred or forked nestjs’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

22posts
Nov 61 post
  1. NestJS 12.2: Why validationError.target Is Now undefined 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, and property each mean on a class-validator ValidationError, and which of them NestJS's own built-in exceptionFactory actually uses - State precisely why @nestjs/core 12.2 changed validationError.target's default to false, in the maintainers' own words - Fix a custom exceptionFactory that 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-problem-an-exceptionfactory-that-used-to-work) - [The mental model: target, value, and property are three different things](#the-mental-model-target-value-and-property-are-three-different-things) - [Stage 1: why 12.2 changed the default](#stage-1-why-122-changed-the-default) - [Stage 2: fixing the broken exceptionFactory](#stage-2-fixing-the-broken-exceptionfactory) - [Stage 3: restoring the old behavior on purpose](#stage-3-restoring-the-old-behavior-on-purpose) - [Stage 4: the quieter 12.2 changes riding along](#stage-4-the-quieter-122-changes-riding-along) - [Edge cases and gotchas](#edge-cases-and-gotchas) - [Best practices](#best-practices) - [FAQ](#faq) - [Cheat sheet](#cheat-sheet) - [Key takeaways](#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: 1. property — the name of the field that failed, e.g. "email". Always present. Unaffected by this change. 2. constraints — a map of rule name to human-readable message, e.g. { isEmail: "email must be an email" }. Always present. Unaffected. 3. 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. 4. 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/core 12.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 exceptionFactory was never affected. @nestjs/common's own default error formatter — the one ValidationPipe uses when you don't pass your own — only ever reads property and constraints to build its message array. If you've never written a custom exceptionFactory, this entire change is invisible to you. - Nested DTOs still carry children, not a nested target. A failure on address.zip produces a ValidationError with children: [{ property: 'address', children: [{ property: 'zip', ... }] }]; target suppression 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 no target concept at all — nothing here applies to it. - A pipe constructed before you set the option doesn't retroactively pick it up. validationError is read once, in the constructor's resolved options — set it when you call new ValidationPipe({ ... }), not after. - Logging error.target to 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 reaches exceptionFactory by default. console.log statements inside your own services that build the object from the raw request body are unaffected either way. Best practices - Default to error.value, not error.target, in any exceptionFactory you 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 exceptionFactory in 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 .target on the result. - Pin the behavior you rely on, not the default. The target: true default 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/core 12.2 defaults validationError.target to false on ValidationPipe — a deliberate change, documented in the v12.2.0 release notes, not a regression. - target is the entire validated object; value is the one failing field's value. Only target's default changed; value never moves. - NestJS's own built-in exceptionFactory never read target — 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 with validationError: { 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. <!-- playground:start --> 🎮 Try it yourself ▶️ Open the interactive playground → _Runs right in your browser — poke at it and watch the concept react live._ <!-- playground:end --> <!-- quiz:start --> 🧠 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._ <!-- quiz:end --> <!-- related:start --> 📚 Read next - NestJS 12: Standard Schema Validation Without class-validator - NestJS Guards: CanActivate, ExecutionContext & Reflector - NestJS Request Lifecycle Explained (with Cheat Sheet) <!-- related:end --> --- 🚀 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 - 📸 Instagram — frontend best practices, daily: @bestpractice___

  2. @nestframework 📈 Stateless, so it scales behind any load balancer 🔄 Clients of the 2026 and 2025 protocol revisions on one endpoint 🔑 OAuth 2.1 built in: your identity provider's tokens, scopes per tool 🧪 An in-memory MCP client for your tests nestjs/mcp

    8 likes · 306 views
  3. TypeScript 7.1 beta's native compiler checks a 353,000-line project in 2.8 seconds instead of 12.4 --- title: "TypeScript 7.1 beta's native compiler checks a 353,000-line project in 2.8 seconds instead of 12.4" published: true published_at: 2026-10-31 13:00 +0200 description: "I benchmarked TypeScript 7's Go-based compiler against 6.0.3 on a real NestJS checkout: 2.5x to 4.5x faster, not Microsoft's claimed 8-12x, and 7.0's stable release used more memory than the old compiler while dropping the API ts-node needs." tags: typescript, javascript, performance, devops ai_disclosure_level: ai_assisted --- TypeScript 7.0 replaced the compiler's JavaScript implementation with a native port written in Go, and Microsoft's own numbers describe a compile that dropped from 125.7 seconds to 10.6 seconds, a gain of roughly 12x. I wanted to know whether that holds on a codebase I didn't choose for a marketing slide, so I checked out a real project and ran all three compilers against it myself: TypeScript 6.0.3, the last release before the rewrite, 7.0.2, the first stable native release, and 7.1.0-beta, which shipped eleven days ago. I used nestjs/nest, cloned fresh and with its real dependencies installed. Its root tsconfig.json pulls in the packages, the integration tests, and the samples: 2,934 files once @types/node and the rest of the dependency graph are counted, about 353,000 lines. tsc --noEmit against that tree is a reasonable stand-in for what a CI job or a pre-commit hook does every day. The headline number Three runs each, fastest reported, same machine, same checkout: | Compiler | Fastest run | Peak memory | |---|---|---| | TypeScript 6.0.3 | 12.42s | 1.13 GB | | TypeScript 7.0.2 | 4.95s | 1.86 GB | | TypeScript 7.1.0-beta | 2.76s | 1.06 GB | 7.0.2 is 2.5x faster than 6.0.3. 7.1.0-beta is 4.5x faster. Both are real, both are worth having, and neither is the 8-12x Microsoft advertises. The VS Code benchmark that produced that number is presumably a bigger, more type-heavy codebase than a mid-sized Node framework, and benchmarks run on your own code will usually look more modest than the one in the release post. If your project looks more like NestJS than like the TypeScript compiler's own source, plan around 2.5x to 4.5x, not 12x. The number that argues against the headline Peak memory went the wrong way for the first stable release. 7.0.2 used more memory than the compiler it replaced, not less, consistently across three runs: 6.0.3: Maximum resident set size: 1,119,044 – 1,128,916 KB 7.0.2: Maximum resident set size: 1,778,072 – 1,863,628 KB 7.1.0-beta: Maximum resident set size: 1,013,640 – 1,056,968 KB A native rewrite is supposed to use less memory than a JavaScript one doing the same job, and in 7.1.0-beta it does, coming in slightly under the old compiler. But 7.0.2, the release most people will actually install because it's the one tagged latest, used about 65% more memory than 6.0.3 at the same task. A CI runner sized around TypeScript 6's memory footprint could OOM on 7.0.2 and recover on 7.1 once that's out of beta. I don't have an explanation for the regression beyond what I could observe: it happened, consistently, on this codebase, and it reversed itself in the next beta. What it refuses The old compiler exposed a JavaScript API: require('typescript') gave you createProgram, transpileModule, createSourceFile, and anything built on top of those. That's gone in 7.0.2. The package's default export is now this: const { version } = require("../package.json"); exports.version = version; exports.versionMajorMinor = "7.0"; That's it. No createProgram. I confirmed it wasn't a module resolution problem on my end by installing ts-node 10.9.2 and pointing it at TypeScript 7.0.2: TypeError: Cannot read properties of undefined (reading 'fileExists') at readConfig (ts-node/dist/configuration.js:91:33) ts.sys doesn't exist, because ts is just { version, versionMajorMinor }. Any tool that calls into the compiler programmatically rather than shelling out to tsc breaks the same way: ts-node, ts-jest, and the type-aware rules in typescript-eslint all depend on createProgram. I expected 7.1.0-beta to fix this, because the beta's headline feature is a stabilized API, and coverage of the release describes it as unblocking Vue, Svelte, and Angular tooling. It does ship a new API, under typescript/sync and typescript/async rather than 7.0's typescript/unstable/sync. I checked the package's export map directly: 7.0.2: "./unstable/sync": "./dist/api/sync/api.js" 7.1.0-beta: "./sync": "./dist/api/sync/api.js" But the default . export is still just version.cjs in 7.1.0-beta too. I reran the exact same ts-node test against it and got the identical crash. "Stabilizes the API" means a new, differently-shaped API exists at a new import path. It does not mean require('typescript').createProgram starts working again. Anything written against the classic API has to be rewritten against the new entry points; nothing becomes compatible by just bumping a version number. Config files break too, in a smaller but more common way. I fed both compilers a set of deprecated tsconfig.json options. Most of them, like target: "ES3" or out, were already hard errors under 6.0.3, just with a different error code. One wasn't: compilerOptions: { "importsNotUsedAsValues": "remove" } 6.0.3: (accepted silently, no output, exit 0) 7.0.2: error TS5023: Unknown compiler option 'importsNotUsedAsValues'. importsNotUsedAsValues has been deprecated in favour of verbatimModuleSyntax for several releases, but 6.0.3 still tolerated it quietly. 7.0 doesn't. Anyone upgrading a tsconfig that was last touched two or three TypeScript versions ago should grep it for options that used to be ignorable warnings, because some of them are now a build that won't start. Under concurrency The release notes mention new flags for parallel project-reference builds, and tsc --help --all on 7.0.2 lists --singleThreaded, meaning parallel is now the default. I built a small composite project, four independent packages referenced from a root tsconfig.json, each containing 400 generated generic type definitions, enough that the per-project check takes a fraction of a second rather than nothing: | Mode | Wall time (3 runs) | |---|---| | 7.0.2, default (parallel) | 0.75s, 0.79s, 0.83s | | 7.0.2, --singleThreaded | 1.38s, 1.40s, 1.48s | | 6.0.3 (no parallel option exists) | 4.26s | Parallelism alone is worth about 1.8x on four independent projects on this four-core machine, separate from the raw speed of the native checker. The two effects stack: the full build goes from 4.26 seconds on the old compiler to under a second on the new one by default, with no flag to set. On a bigger monorepo with more independent project references and more cores, I'd expect that gap to widen rather than narrow, because the whole point of --singleThreaded existing as an escape hatch is that someone will eventually need to turn the parallelism off, not that most people will want to. Watching it in production tsc --extendedDiagnostics still works, and the breakdown shows where the time actually went on the NestJS checkout: 6.0.3 7.0.2 7.1.0-beta Parse time: 1.16s 0.419s 0.291s Bind time: 0.61s 0.093s 0.093s Check time: 9.91s 4.603s 2.502s Total time: 12.21s 5.146s 2.910s Check time is where almost all of the wall clock goes in every version, and it's also where the native compiler's advantage is largest proportionally, not just absolutely. If you're deciding whether upgrading is worth the breakage above, this is the number to look at in your own --extendedDiagnostics output before and after: a codebase that spends most of its time in parsing rather than checking (lots of files, not much generic-heavy type logic) will see a smaller win than NestJS did here. I'd treat the Symbols and Types counts in that same output with caution, though: they roughly doubled between 6.0.3 and 7.0.2 on an identical checkout, which almost certainly reflects a different internal counting convention in the rewrite rather than the new compiler creating twice as many actual symbols, and I have no way to confirm which from the outside. What I got wrong on the way My first run of the headline benchmark gave suspiciously good numbers: all three compilers finished in about a quarter of a second, uniformly, with exactly one error each. I almost wrote that down as "TypeScript 7 is absurdly fast even before you account for anything else," which should have been the first clue, because a result that flattering is usually the harness, not the finding. The one error was TS2688: Cannot find type definition file for 'node', because I'd installed NestJS's dependencies with a plain npm install that hit a peer-dependency conflict and silently produced no node_modules directory at all. Every compiler was failing to even construct the program, immediately, before checking a single file, and reporting an identical fast "success." Reinstalling with --legacy-peer-deps fixed it and pushed every run from a quarter of a second to several seconds, which is when the real differences between the three versions showed up. Run it yourself This installs three versions of TypeScript side by side without touching a global install, clones NestJS, and runs the headline comparison: mkdir -p tsbench/engines && cd tsbench for v in 6.0.3 7.0.2 7.1.0-beta; do npm install --prefix "engines/ts_${v//[.-]/_}" --silent "typescript@$v" done git clone --depth 1 https://github.com/nestjs/nest.git target/nest npm install --prefix target/nest --ignore-scripts --legacy-peer-deps --silent for v in 6_0_3 7_0_2 7_1_0_beta; do echo "=== $v ===" TSC="engines/ts_$v/node_modules/.bin/tsc" time "$TSC" --noEmit -p target/nest/tsconfig.json done The --legacy-peer-deps flag matters: without it, npm install can fail outright on a conflicting peer dependency, or in the worse case leave you with no node_modules and every compiler reporting the same hollow "success" I got on my first attempt. Check the file count and error count match across versions before you trust a timing number from this, not just the time itself. What to do with this If you're on TypeScript 6 and your build is dominated by type-checking rather than I/O, 7.0 is worth the upgrade on speed alone, even at 2.5x rather than 12x. Before you do it, grep your tsconfig files for options your team hasn't touched in a while, because some of yesterday's warnings are today's build failures. And if your toolchain includes ts-node, ts-jest, or anything doing type-aware linting, check whether it has shipped a release that targets TypeScript 7's new API paths before you bump the version in CI; the error if it hasn't is a flat crash, not a graceful fallback, and 7.1's "stabilized API" doesn't change that for code still written against the old one.

    9 reactions · 1 comment
  4. Reddit · · r/u_Research_Junkie1 · u/Research_Junkie1 · nestjs/nest

    Tracing one NestJS request: handlers, response wrapping and test boundaries An illustrative walkthrough of GET /cats in Nest’s official public 01-cats-app sample. It follows registered modules, the route-specific guard decision, the in-memory service and global response transformation, then distinguishes the HTTP test’s service override from real data behavior. This is not an analysis of anyone’s private application; the test source was inspected, not executed. Pinned sample: github.com/nestjs/nest/tree/060fae304f9cec0677110… Request lifecycle: docs.nestjs.com Produced with Armature: app.armature.ai reddit.com

  5. NestJS 12 Lifecycle Hooks: The Order You Can't Assume A NestJS app upgrades from v11 to v12. No application code changes — just npm i @nestjs/core@latest @nestjs/common@latest. The test suite, which has been green for months, starts failing intermittently: a report service reads a cache that a metrics service is supposed to have already populated, and about one run in three, it hasn't been. Nothing in the diff touches either service. The bug was always there — it just used to be invisible, because the old, undocumented order happened to keep working. The official v12.0.0 release notes list the cause in one line, under breaking changes: "Lifecycle hooks are now invoked by component hierarchy level." The fix isn't a workaround for v12 — it's realizing that "hook order" was never a promise Nest made for providers like these two. What you'll learn By the end of this article you'll be able to: - Name every NestJS lifecycle hook and state precisely when each one fires - Explain which part of hook ordering Nest actually guarantees — and which part it has always reserved the right to change - Recognize the v12 breaking change for what it is, and know why it broke code that looked correct - Fix a hook-ordering bug the right way: with dependency injection, not a delay or a flag - Enable and reason about shutdown hooks (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown) correctly Who this is for You've written at least one onModuleInit() in a NestJS service and injected a provider into another provider's constructor. You don't need any prior lifecycle-hook experience beyond that — this article builds the full model from there. This article is written against NestJS 12.x (verified against @nestjs/core's npm dist-tags and the nestjs/nest GitHub release history: latest is 12.2.0, v12.0.0 shipped August 27, 2026, and the 11.x line has moved to the legacy npm tag at 11.2.7). The point releases since v12.0.0 (through 12.2.0) fix shutdown-sequence and startup-failure edge cases but don't change the onModuleInit/onApplicationBootstrap ordering model described below. Everything here is core-container behavior — it works identically whether your HttpAdapter is Express or Fastify, since lifecycle hooks fire before Nest ever hands a request to either one. Table of contents - [The problem: a hook order nobody promised](#the-problem-a-hook-order-nobody-promised) - [The mental model: constructed order vs. hook order](#the-mental-model-constructed-order-vs-hook-order) - [Stage 1: the five lifecycle hooks](#stage-1-the-five-lifecycle-hooks) - [Stage 2: the order Nest does guarantee](#stage-2-the-order-nest-does-guarantee) - [Stage 3: the order it never guaranteed — and v12 changed](#stage-3-the-order-it-never-guaranteed-and-v12-changed) - [Stage 4: the fix is DI, not a delay](#stage-4-the-fix-is-di-not-a-delay) - [Stage 5: shutdown hooks and async hooks](#stage-5-shutdown-hooks-and-async-hooks) - [Edge cases and gotchas](#edge-cases-and-gotchas) - [Best practices](#best-practices) - [FAQ](#faq) - [Cheat sheet](#cheat-sheet) The problem: a hook order nobody promised Here's the setup, trimmed to the part that matters: // metrics.module.ts let requestCounterSeed = 0; @Injectable() export class MetricsService implements OnModuleInit { onModuleInit() { requestCounterSeed = loadSeedFromDisk(); // populates module-scope state } } // reports.service.ts @Injectable() export class ReportsService implements OnModuleInit { private baseline = 0; onModuleInit() { this.baseline = requestCounterSeed; // reads it back — no DI involved } } ReportsService never injects MetricsService. There's no constructor(private metrics: MetricsService) anywhere — the two talk through a module-scope variable instead, and the code "works" as long as MetricsService's onModuleInit happens to run before ReportsService's. Under Nest 10 and 11, it reliably did — because hook order followed each module's position in AppModule's imports array, and MetricsModule happened to be listed first. It is not a coincidence that this looks fragile. It is fragile — it was fragile before the upgrade too. The v12 release note doesn't introduce the bug; it removes the accident that was hiding it. Once hook invocation moved to being ordered "by component hierarchy level" instead of import position, ReportsService.onModuleInit can run first, and requestCounterSeed is still 0 when it reads it. The mental model: constructed order vs. hook order The mental model: NestJS gives you exactly one hard guarantee about initialization order, and it comes from the dependency graph, not from where a hook happens to live. If provider B is constructor-injected into provider A, Nest must fully construct B — and run B's own onModuleInit — before it can finish constructing A and run A's onModuleInit. That ordering is real, structural, and the same in every version, because it falls directly out of how constructor injection works: you cannot hand A a B that doesn't exist yet. Everything else — the order of two providers that share no dependency edge, sitting in sibling modules that never import each other — has never been part of that contract. It happened to be stable for a long time because Nest's internal bootstrap walked the module tree in a specific, observable order. "Observable" is not "guaranteed": nothing in Nest's public API ever promised that two unrelated providers would always initialize in the same relative order, which is exactly why the v12 release notes can change it and call it a breaking change rather than a bug fix — it was breaking an assumption, not a contract. Key concept: if two providers' initialization order matters to your code, that requirement has to be expressed as a dependency — an injected constructor parameter — not inferred from import position. If there's no edge in the graph, there's no promise about the order. <!-- playground:start --> 🎮 Try it yourself ▶️ Open the interactive playground → _Runs right in your browser — poke at it and watch the concept react live._ <!-- playground:end --> Stage 1: the five lifecycle hooks Nest exposes five lifecycle interfaces a provider (or module class) can implement. Each is a plain TypeScript interface with one method, and implementing it is how you opt in: import { OnModuleInit, OnApplicationBootstrap, OnModuleDestroy, BeforeApplicationShutdown, OnApplicationShutdown, } from '@nestjs/common'; - onModuleInit() — called once the host module's own dependencies have been resolved and constructed. This is the hook for "set up something that needs this provider's injected dependencies to already exist." - onApplicationBootstrap() — called once every module in the app has finished onModuleInit, but before Nest starts listening for connections. Use it for work that should only start once the whole graph is up, not just this provider's own corner of it. - onModuleDestroy() — the mirror of onModuleInit, run during teardown. - beforeApplicationShutdown(signal?: string) — runs after onModuleDestroy, with access to the OS signal (SIGTERM, SIGINT) that triggered shutdown, if there was one. - onApplicationShutdown(signal?: string) — the last hook to run, immediately before the process would otherwise exit. onModuleInit and onApplicationBootstrap only fire if you actually call app.init() or app.listen() — a TestingModule you only compile() but never init() skips both, which is a common source of "why didn't my hook run in this test" confusion. Stage 2: the order Nest does guarantee The one ordering rule worth memorizing: a provider's onModuleInit runs after the onModuleInit of every provider it depends on, transitively. This is a direct consequence of constructor injection being resolved depth-first. @Injectable() export class CacheService implements OnModuleInit { onModuleInit() { console.log('CacheService ready'); } } @Injectable() export class OrdersService implements OnModuleInit { constructor(private readonly cache: CacheService) {} onModuleInit() { console.log('OrdersService ready'); // always logs after CacheService } } Because OrdersService injects CacheService, "CacheService ready" is guaranteed to print before "OrdersService ready" — in Nest 10, 11, 12, and every future version, because changing it would mean handing OrdersService a CacheService whose own setup hasn't run yet. That's not an implementation detail Nest could casually break; it's the definition of what constructor injection means. onApplicationBootstrap extends the same guarantee one level further: because it only fires after every module's onModuleInit has completed, anything you do there can safely assume the entire app's onModuleInit work — dependency-ordered or not — is already done. Stage 3: the order it never guaranteed — and v12 changed What Nest has never promised is the relative order between two providers that share no dependency edge — like MetricsService and ReportsService above. Before v12, that order was a side effect of how Nest walked the module tree (roughly, each module's imports in declaration order, depth-first). It was deterministic in practice, which is exactly what made it easy to depend on by accident. The v12.0.0 release notes list the actual change under breaking changes, verbatim: "Lifecycle hooks are now invoked by component hierarchy level." The notes don't spell out the full new algorithm step by step — their own guidance is to "review ordering assumptions between related providers/modules in init, teardown, and tests" rather than to assume your old mental model of "import order" still predicts anything. That's the honest, practical takeaway: don't try to reverse-engineer the new implicit order and depend on that instead — it's exactly the same mistake with a different answer. Stage 4: the fix is DI, not a delay The wrong fixes are tempting and all fragile: reordering imports in AppModule (back to relying on an undocumented detail, just a different one), adding a setTimeout before reading the shared state (a race condition with extra steps), or introducing a global "ready" flag with a polling loop. All three fix today's symptom and leave the actual defect — an implicit dependency — in place. The real fix is to make the dependency explicit, the same way you would for any other cross-service call: @Injectable() export class ReportsService implements OnModuleInit { private baseline = 0; constructor(private readonly metrics: MetricsService) {} onModuleInit() { this.baseline = this.metrics.getSeed(); // guaranteed: metrics already initialized } } @Injectable() export class MetricsService implements OnModuleInit { private seed = 0; onModuleInit() { this.seed = loadSeedFromDisk(); } getSeed() { return this.seed; } } Once ReportsService constructor-injects MetricsService, Stage 2's guarantee applies again: Nest cannot construct ReportsService without MetricsService already existing, and MetricsService.onModuleInit is guaranteed to have already run. The module-scope variable is gone, replaced by a method call on an instance Nest already knows how to order correctly — because now there's an edge in the graph for it to order by. Key concept: a lifecycle-ordering bug is almost always a missing edge in the dependency graph wearing a disguise. Add the edge (inject the thing), and the ordering problem disappears on its own — you don't have to solve it, Nest already does. Stage 5: shutdown hooks and async hooks Every hook in Stage 1 can be async — return a Promise, and Nest waits for it before moving to the next stage. An async onModuleInit() that awaits a database connection genuinely blocks app.listen() until it resolves, which is usually exactly what you want (don't accept traffic before you can serve it) but is worth knowing explicitly, since a slow connection now shows up as a slow boot, not a slow first request. Teardown hooks (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown) only run on app.close() or an OS shutdown signal — and only if you've opted in: // main.ts const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); // without this, SIGTERM/SIGINT skip teardown hooks entirely await app.listen(3000); Forgetting enableShutdownHooks() is the most common reason a service that's supposed to flush a queue or close a connection pool on shutdown silently doesn't — the process just exits, and no teardown hook ever runs. <!-- quiz:start --> 🧠 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._ <!-- quiz:end --> Edge cases and gotchas - Tests that only compile(), never init(). Test.createTestingModule({...}).compile() builds the module graph but never calls onModuleInit or onApplicationBootstrap. If a hook-driven side effect doesn't appear to run in a test, check whether the test ever called .init() on the result. - Circular dependencies with forwardRef(). Two providers that depend on each other via forwardRef(() => OtherService) can't have a clean "B before A" order, because there is no clean order — Nest resolves the cycle, but a hook in either one that assumes the other is fully initialized can still race. Prefer breaking the cycle over patching around it. - Global modules aren't a shortcut around ordering. A module marked @Global() is visible everywhere without importing it, but it is still initialized according to the same rules — "global" changes visibility, not hook order. - onApplicationBootstrap running late in a slow-starting app. Because it waits for every module's onModuleInit, one slow async hook anywhere in the graph delays onApplicationBootstrap for the whole app, not just its own module. - Upgrading from 11.x to 12.x with hidden cross-module hook assumptions. This is the v12-specific case this article opened with. If a test suite starts flaking right after the upgrade, treat it as a real latent bug the version bump exposed, not a Nest regression to work around. Best practices - Never let a hook in one provider assume another unrelated provider already ran unless that assumption is backed by a real constructor-injection edge. If it isn't, add the edge. - Use onApplicationBootstrap for anything that needs the whole app ready, not just its own module's dependencies — don't reach for a hack to delay onModuleInit work instead. - Call app.enableShutdownHooks() in production if any provider relies on onModuleDestroy or onApplicationShutdown to flush state or close connections cleanly. - When you upgrade a major Nest version, read the release's breaking-changes list before you read anything else — a change like this one produces no compile error and no obvious runtime error, only an intermittent one. - If you genuinely need to verify ordering empirically, log each hook with its provider's name during boot rather than assuming either the "old" or "new" order from memory — the release notes don't spell out the full new algorithm, so the only way to know your app's actual order is to look at it. FAQ Does onModuleInit run in constructor order? Only where a real dependency edge exists. If A injects B, B's onModuleInit is guaranteed to run first. Between providers with no such edge, there's no guaranteed order, and NestJS 12's release notes confirm that relative order can and did change. Why didn't my onModuleDestroy fire when I sent SIGTERM? Almost always because app.enableShutdownHooks() was never called. Without it, Nest doesn't attach the OS signal listeners that trigger the teardown hooks at all. Does an async onModuleInit block app.listen()? Yes. Nest awaits every onModuleInit (and then every onApplicationBootstrap) before it starts accepting connections, so a slow one delays the whole app's startup, not just the part that's slow. Is this different for Fastify vs Express? No. Lifecycle hooks fire during module bootstrap, entirely before Nest hands anything to the underlying HTTP adapter, so the behavior is identical regardless of which one you're using. Will NestJS 13 reorder hooks again? There's no way to know in advance, and that uncertainty is the actual point of this article: code that depends only on guaranteed ordering (real dependency edges) can't be broken by a future reshuffle of the unguaranteed part, because it never relied on it. Cheat sheet | Hook | Fires when | Guaranteed relative to | | --- | --- | --- | | onModuleInit() | Host module's own dependencies constructed | After every injected dependency's onModuleInit | | onApplicationBootstrap() | Every module's onModuleInit has finished | After all onModuleInit hooks app-wide | | onModuleDestroy() | app.close() or shutdown signal (needs enableShutdownHooks()) | Mirrors onModuleInit's dependency order, in reverse | | beforeApplicationShutdown(signal) | After all onModuleDestroy hooks | After every onModuleDestroy | | onApplicationShutdown(signal) | Last hook before process exit | After beforeApplicationShutdown | | Two providers, no dependency edge | — | Not guaranteed, by design — don't rely on it | | Opt in to teardown hooks | app.enableShutdownHooks() in main.ts | Must be called explicitly | | Fix an ordering bug | Constructor-inject the provider you actually depend on | Turns an assumption into a real graph edge | Key takeaways - NestJS guarantees hook order along the dependency graph: if A injects B, B's hooks always run first. That rule has never changed and never will, because it's a consequence of constructor injection, not a scheduling choice. - It has never guaranteed order between providers with no dependency edge — that was always an implementation detail, and NestJS 12's release notes confirm it changed. - A hook-ordering bug is a missing dependency edge in disguise. The fix is to inject the thing you depend on, not to chase the new implicit order. - Teardown hooks need app.enableShutdownHooks() to run at all — a very common silent gap. The report service from the top of this article didn't start being wrong the day it was upgraded to Nest 12 — it was wrong from the day someone reached for a module-scope variable instead of a constructor parameter. The version bump just stopped covering for it. Once you're reading "hook order" as "the dependency graph, and nothing else," that whole bug class stops being a mystery you debug after an upgrade and starts being something you can rule out on sight. Has a major-version upgrade ever exposed an ordering assumption you didn't know your code was making? Drop it in the comments. <!-- related:start --> 📚 Read next - NestJS Dependency Injection Explained (with Cheat Sheet) - NestJS Testing Module: Provider Overrides (with Cheat Sheet) - NestJS Module Encapsulation Explained (with Cheat Sheet) <!-- related:end --> --- 🚀 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 - 📸 Instagram — frontend best practices, daily: @bestpractice___

    5 reactions
  6. NestJS 12 Migration Guide: Upgrading from v11, Tested and Benchmarked NestJS 12 is the first major release of the Node.js framework since v11. It was released on August 27, 2026, and 12.1.3 is the current version. The headline changes are ESM-only packages, built-in Standard Schema validation (so Zod, Valibot and ArkType work without a custom pipe), a rebuilt CLI with a nest upgrade command, Vitest and oxlint as defaults for new projects, and the new @nestjs/observe SDK. Existing CommonJS apps keep working. The release notes cover what changed. This guide covers what happened when I actually upgraded. I built a small but realistic NestJS 11 API with class-validator DTOs, Joi config validation, lifecycle hooks, an @Optional() dependency and e2e tests. I ran nest upgrade on it, fixed everything that broke, and benchmarked v11 against v12 for startup, throughput and memory. I also measured Zod against class-validator, Jest against Vitest, ESLint against oxlint, TypeScript 6 against TypeScript 7, and NestJS 12 on Bun. Every number below comes from those runs. TL;DR: should I upgrade to NestJS 12? Yes, but budget an hour rather than a minute. nest upgrade bumped every package and rewrote my config module correctly, but the build and the test suite both failed afterwards. TypeScript 6 needs three tsconfig fixes, and Jest needs --experimental-vm-modules to load the ESM-only packages. Once those are fixed, request throughput is the same as v11 (within 5%), cold start is about 90 ms slower, and you can move to Zod validation, which was 28% faster than class-validator in my test. | | NestJS 11.2.7 | NestJS 12.1.3 | | --- | --- | --- | | Package format | CommonJS | ESM only (CJS apps work via require(esm)) | | Minimum Node.js to run | 20 | 20.19+ or 22.12+ | | TypeScript in a new project | 5.x | 6.x (7.0 not yet supported by nest build) | | Validation | class-validator ValidationPipe | class-validator or Standard Schema (Zod, Valibot, ArkType) | | New-project defaults | Jest, ESLint, CommonJS | Vitest, oxlint, ESM | | Cold start, my API (median) | 296 ms | 386 ms (CJS), 363 ms (ESM) | | Hello route, req/s | 36,777 | 34,866 (CJS), 36,789 (ESM) | | POST with validation, req/s | 17,173 | 16,237 class-validator, 20,804 Zod | What's new in NestJS 12? The NestJS v12.0.0 release groups the changes into five areas: - ESM packages. Every core @nestjs/* package now ships as ESM. A CommonJS app loads them through Node's require(esm), so you don't have to convert your own code. - Standard Schema. @Body(), @Query(), @Param() and @RawBody() take a schema option. The new StandardSchemaValidationPipe and StandardSchemaSerializerInterceptor validate requests and responses with any Standard Schema library. @nestjs/config now accepts a Zod schema as validationSchema too. - A rebuilt CLI. It adds nest upgrade (with --dry-run), nest deploy, Rspack as the default bundler for monorepos, and bun as a package manager. nest new scaffolds ESM, Vitest and oxlint by default. - Native observability. @nestjs/observe hooks into Nest's own request lifecycle instead of patching the HTTP server like a generic APM agent. - Smaller additions. These include routeConflictPolicy and routeResolutionStrategy for route shadowing, machine-readable errorCode on HTTP exceptions, structured logging params, and graceful shutdown in the Express adapter. The breaking changes that matter most for typical REST APIs are listed in the official migration guide: ESM packages, lifecycle hook ordering by component hierarchy, @Optional() inheritance, @nestjs/config moving to Standard Schema (Joi must be v18+), NATS v3, graphql-ws-only subscriptions, and the removal of the legacy Terminus health indicator API. How I tested the NestJS 12 upgrade - App: a NestJS 11.2.7 project scaffolded with CLI 11.0.24 (Express adapter), plus a users module. It has a class-validator DTO, @nestjs/config 4 with a Joi 17 schema and custom validationOptions, onModuleInit and onApplicationBootstrap hooks, a repository subclass that inherits an @Optional() constructor dependency, a GET /users/me route declared after GET /users/:id, and Jest e2e tests with supertest. - Upgrade: CLI 12.0.8 nest upgrade, which installed NestJS 12.1.3, @nestjs/config 12.0.1 and Joi 18.3.0. - Runtime: Node.js 24.22.0 on an 8 vCPU Xeon VM. Node.js 26.12.0 and Bun 1.4.3 were used for the cross-runtime checks. - Benchmarks: hyperfine for startup and tooling times (20 runs for startup, 5 to 8 for tools). For HTTP I used oha with 64 connections, taking the median of three 10-second runs after a warm-up, with the server pinned to 2 cores and oha on the other 6. "Cold start" means the wall time to start the process, boot Nest, bind the port and close it again. Inside the process, NestFactory.create() plus listen() took 70 ms on v11 and 84 to 93 ms on v12. The rest is Node starting and loading modules. How do I upgrade to NestJS 12? The upgrade has five steps. The first trips almost everyone coming from a v11 project. Step 1: upgrade the CLI (and TypeScript with it) The guide says to upgrade the CLI first, both globally and in your project. On my v11 project the local install failed: $ npm i -D @nestjs/cli@latest @nestjs/schematics@latest npm error ERESOLVE unable to resolve dependency tree npm error Found: typescript@5.9.3 npm error peer typescript@">=6.0.0" from @nestjs/schematics@12.0.7 @nestjs/schematics 12 needs TypeScript 6 or newer as a peer, and a v11 project has 5.x. Skipping the local install doesn't help either. The global v12 binary hands off to the project's local v11 CLI, which answers Invalid command: upgrade. The fix is to bump TypeScript in the same command: npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6 Use TypeScript 6, not 7. typescript@latest is 7.0.2 now, and the Nest CLI can't build with it yet (more on that below). The schematics also need Node.js 22.22.3+, 24.15+ or 26+, which is a higher floor than running the app. Step 2: run nest upgrade --dry-run npx nest upgrade --dry-run On my app, the dry run printed this report and changed nothing: Changes applied: ✔ Updated NestJS packages: @nestjs/common@^12.0.0, @nestjs/core@^12.0.0, @nestjs/testing@^12.0.0, @nestjs/platform-express@^12.0.0, @nestjs/cli@^12.0.0, @nestjs/schematics@^12.0.0, @nestjs/config@^12.0.0 ✔ Updated "ts-jest" from "^29.2.5" to "^29.4.0" (first release supporting Jest 30) ✔ /src/app.module.ts: moved "validationOptions" settings under "validationOptions.libraryOptions" ✔ Updated "joi" from "^17.13.8" to "^18.0.0" (Joi v18 implements Standard Schema, required by @nestjs/config v12) Notes: ℹ Lifecycle hooks are now invoked by component hierarchy level (found in /src/users/users.service.ts) Please review: ⚠ Jest: the NestJS 12 packages are ESM-only and Jest can only require() ESM modules on Node.js v24.9+ Action required: ➜ tsconfig.build.json does not set "rootDir". TypeScript 6 requires it explicitly (error TS5011) It's a good report. It found the Joi schema, the config option that needs to move, and the file that uses lifecycle hooks. Add --no-observe (or --observe) to skip the prompt about setting up @nestjs/observe. Step 3: run nest upgrade npx nest upgrade --no-observe This took 6 seconds including the install. It changed 3 files: package.json, package-lock.json and one line in app.module.ts: - validationOptions: { allowUnknown: true, abortEarly: true }, + validationOptions: { libraryOptions: { allowUnknown: true, abortEarly: true } }, It doesn't touch your module format, so a CommonJS app stays CommonJS. It doesn't switch you to Vitest or oxlint either. Step 4: fix the TypeScript 6 errors After the upgrade, nest build failed with five errors: error TS5011: The common source directory of 'tsconfig.build.json' is './src'. The 'rootDir' setting must be explicitly set error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. src/users/create-user.dto.ts:3:14 - error TS2564: Property 'email' has no initializer and is not definitely assigned in the constructor. These come from TypeScript 6, not from NestJS. TypeScript 6 turns strict on by default, which brings in strictPropertyInitialization, and that flags every class-validator DTO property. It also deprecates baseUrl and makes rootDir explicit. The v12 project template sets these options: // tsconfig.json { "compilerOptions": { "rootDir": ".", "strictPropertyInitialization": false // and delete "baseUrl": "./" unless you use path aliases } } // tsconfig.build.json { "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "./src" }, "include": ["src"], "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } The upgrade report only mentioned tsconfig.build.json. My e2e tests use the base tsconfig.json through ts-jest, and they failed with the same TS5011 error until I added "rootDir": "." there too. If you use path aliases, replace baseUrl with paths entries relative to the tsconfig, or set "ignoreDeprecations": "6.0" for now. My TypeScript 7 upgrade guide covers the rest of the 6.0 and 7.0 changes. Step 5: make Jest load the ESM packages Once it compiled, every Jest suite failed: Must use import to load ES Module: node_modules/@nestjs/testing/index.js - Use Node v24.9+ where Jest supports require(esm) natively I was already on Node 24.22, and Node 26.12 failed the same way. Jest 30.5.2 only takes its ESM path when vm.SyntheticModule exists, and Node only exposes it behind a flag. Setting that flag fixed all six tests: "test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json" (Use cross-env if you need Windows support.) The alternative is to move to Vitest, which is what new v12 projects use. More on that below. Which NestJS 12 breaking changes actually bit? @Optional() inheritance depends on the exact v12 version The migration guide says a subclass without its own constructor no longer inherits its parent's @Optional() markers, and that Nest now throws UnknownDependenciesException where v11 injected undefined. I tested this directly with @nestjs/testing: | Case | 11.2.7 | 12.0.0 to 12.0.4 | 12.1.0 to 12.1.3 | | --- | --- | --- | --- | | Subclass with no constructor | resolves | throws | resolves | | Subclass that redeclares the constructor without @Optional() | resolves | throws | throws | On v11, both subclasses silently got undefined for the missing dependency. 12.1.0 added a lookup that finds the class which actually declares the constructor, which brought the inherited markers back for subclasses without a constructor. A subclass that redeclares its constructor without @Optional() throws on every v12 release, and on early 12.0.x releases the constructor-less subclass throws too. Declare the marker again on the subclass: @Injectable() class UsersRepo extends BaseRepo { constructor(@Optional() @Inject(AUDIT) audit?: AuditLog) { super(audit); } } Lifecycle hook order nest upgrade flagged my hooks file. The order of onModuleInit and onApplicationBootstrap across my repository and service was identical on v11 and v12. The change only affects providers at different levels of the component hierarchy. If your teardown or startup code depends on another provider's hook having run, add a test that checks the order. Route shadowing is still opt-in GET /users/me declared after GET /users/:id still returned the :id handler's 400 error on v12, the same as v11. The new options are off by default. Turn them on to get warnings and literal-before-parameter ordering: const app = await NestFactory.create(AppModule, { routeConflictPolicy: { duplicate: 'error', shadow: 'warn' }, routeResolutionStrategy: 'specificity', }); With both set, /users/me returned {"me":true}, and Nest logged a WARN line for each literal and parameter route pair that overlaps. I recommend turning both on in new apps. How do I use Zod for validation in NestJS 12? Add a schema to the parameter decorator and register StandardSchemaValidationPipe. No custom pipe or extra package is needed: import { z } from 'zod'; export const createUserSchema = z.object({ email: z.email(), name: z.string().min(2), age: z.number().int().min(13).max(120), }); export type CreateUser = z.infer<typeof createUserSchema>; @Controller('zusers') export class ZUsersController { constructor(private users: UsersService) {} @Post() create(@Body({ schema: createUserSchema }) body: CreateUser) { return this.users.create(body); } @Get(':id') find(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) { return this.users.find(id); } } // main.ts: both pipes can run side by side during a migration app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true }), new StandardSchemaValidationPipe(), ); With both pipes registered globally, class-validator DTO routes and Zod routes work in the same app. Each pipe only acts on parameters it recognises, so you can migrate one controller at a time. Invalid input returns the usual 400 response, with messages prefixed by field path: {"message":["email: Invalid email address","name: Too small: expected string to have >=2 characters","age: Too small: expected number to be >=13"],"error":"Bad Request","statusCode":400} Unknown keys are stripped by default, as with whitelist: true, because z.object drops them. On the same app, server and load, POST /zusers with Zod 4.6.7 handled 20,804 req/s, and POST /users with class-validator handled 16,237 req/s. That makes Zod 28% faster, with p99 latency of 6.2 ms against 7.3 ms. Zod also gives you a single source for the TypeScript type, the validation and the OpenAPI schema. On v12 I'd use it for new endpoints. class-validator stays fully supported, so there's no need to rewrite working DTOs. !NestJS 11 vs 12 benchmark: cold start 296 ms on v11, 386 ms on v12 CommonJS, 363 ms on v12 ESM; requests per second for hello, GET user and POST with validation, with Zod at 20,804 vs class-validator at 16,237 Machine-readable error codes come with v12 too. throw new BadRequestException('Password is too weak', { errorCode: 'WEAK_PASSWORD' }) returned {"message":"Password is too weak","error":"Bad Request","statusCode":400,"errorCode":"WEAK_PASSWORD"}, so clients can branch on a stable code instead of parsing the message. Is NestJS 12 slower than NestJS 11? Request handling is not slower. Startup is. | Metric (Node 24.22) | v11.2.7 | v12.1.3 CJS | v12.1.3 ESM | | --- | --- | --- | --- | | Cold start, median | 296 ms | 386 ms | 363 ms | | Peak RSS during start | 94 MB | 108 MB | 108 MB | | Import core + common + platform-express | 212 ms | 262 ms | 256 ms | | GET / hello, req/s | 36,777 | 34,866 | 36,789 | | GET /users/:id, req/s | 29,998 | 28,387 | 30,251 | | POST /users + class-validator, req/s | 17,173 | 16,237 | 16,921 | Throughput moved by 5% or less in either direction, which is within the run-to-run variation. Cold start grew by about 90 ms (30%) in a CommonJS app, and most of that is the framework's own import cost: 50 ms more just to load the three core packages. The CJS app's require(esm) path is not the main cause, because the same app converted to ESM was only 23 ms faster. Adding Zod and a second controller added another 44 ms. For a long-running API, 90 ms at boot doesn't matter. For serverless and scale-to-zero it does, and two cheap fixes help: - Enable Node's compile cache. With NODE_COMPILE_CACHE=/tmp/node-cache, v12 started in 338 ms instead of 386 ms, and v11 in 257 ms. - Run on Bun. The same v12 ESM build started in 264 ms on Bun 1.4.3, faster than v11 on Node. Node 26.12 started the v12 app in 355 ms, close to Node 24 with the compile cache. Should I convert my NestJS app to ESM? You don't have to: nest upgrade leaves CommonJS apps alone, and they run fine. Converting gave me 23 ms of startup and nothing else measurable. Do it when a dependency you need is ESM-only and uses top-level await (which require(esm) can't load), or when you want Vitest without extra configuration. The conversion took me ten minutes on a small app: 1. Add "type": "module" to package.json. Projects generated with @nestjs/schematics 11.0.6+ already use "module": "nodenext", so tsconfig needs no other change. 2. Add .js to every relative import ('./app.module.js'). A one-line regex changed 9 files. 3. Replace __dirname with import.meta.dirname and call await bootstrap(). 4. Fix namespace imports of CommonJS packages. My build passed, but startup crashed with TypeError: Joi.string is not a function until I changed import * as Joi from 'joi' to import Joi from 'joi'. TypeScript doesn't catch this one, so start the app after converting. Jest vs Vitest and ESLint vs oxlint in NestJS 12 A new v12 project (nest new, which defaulted to ESM when run without a TTY) gets this package.json: "type": "module", vitest 4 with vite 8, oxlint plus oxlint-tsgolint for type-aware rules, and TypeScript 6. There's no SWC plugin. I copied the template's Vitest config into my converted app, and all six e2e tests passed unchanged, including DI that depends on decorator metadata. | Task (same tests or same files) | Old default | New default | | --- | --- | --- | | e2e suite, NestJS 11 + Jest 30 | 1.22 s | | | e2e suite, NestJS 12 | 1.62 s (Jest + vm-modules) | 0.98 s (Vitest 4.1) | | Lint src/ and test/ | 2.14 s (ESLint 9 + typescript-eslint) | 0.39 s (oxlint 1.88, type-aware) | | Type-check (tsc --noEmit) | 0.98 s (TypeScript 6.0.3) | 0.31 s (TypeScript 7.0.2) | Jest got 33% slower on v12 because of the ESM loading path. Vitest was 40% faster than that, and 20% faster than Jest on v11. oxlint was 5.5 times faster than ESLint on this small codebase, and the gap grows with more files. It's not a drop-in replacement, though. ESLint reported 61 problems (35 of them Prettier formatting and 25 no-unsafe-* rules), while oxlint reported one warning that ESLint didn't (no-misused-spread). Keep Prettier as a separate step if you move to oxlint. Does NestJS 12 support TypeScript 7? You can't build NestJS 12 with TypeScript 7 through the Nest CLI yet. With typescript@7.0.2 installed, nest build stops with: Error The installed TypeScript version (7.0.2) does not expose the programmatic compiler API that the Nest CLI requires. TypeScript 7.0 ships the "tsc" executable only; the compiler API is expected to return in 7.1. Please install TypeScript 6 (e.g. "npm i -D typescript@^6") until then. nest build -b swc stops with the same error. Plain tsc -p tsconfig.build.json from TypeScript 7 compiled the app correctly, and the output booted normally. So if you want the 3x faster type-check now, run tsc 7 for type-checking and builds in CI, and keep TypeScript 6 for nest start --watch until 7.1. Can I run NestJS 12 on Bun? Yes. The compiled ESM app ran unchanged with bun dist/main.js, including class-validator, Joi config and Express. Under the same load test, Bun 1.4.3 beat Node 24.22: | NestJS 12.1.3 ESM app | Node 24.22 | Bun 1.4.3 | | --- | --- | --- | | Cold start | 361 ms | 264 ms | | GET / hello, req/s | 36,789 | 54,867 (1.49x) | | GET /users/:id, req/s | 30,251 | 48,553 (1.61x) | | POST /users + class-validator, req/s | 16,921 | 22,158 (1.31x) | The v12 CLI also accepts bun as a package manager. For the broader trade-offs (memory over time, APM agents, native addons), see my Bun vs Node benchmark. !NestJS 12 upgrade path: bump CLI with TypeScript 6, nest upgrade --dry-run, nest upgrade, fix tsconfig rootDir and strictPropertyInitialization, set Jest vm-modules or move to Vitest, then adopt Zod and route conflict policy NestJS 12 upgrade checklist 1. Make sure you're on Node.js 22.22.3+, 24.15+ or 26 for the CLI. The app itself needs only 20.19+ or 22.12+. 2. Run npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6. 3. Run npx nest upgrade --dry-run and read the report. 4. Run npx nest upgrade --no-observe and review the diff. 5. tsconfig: add rootDir to both files, remove baseUrl, and set strictPropertyInitialization: false if you use class DTOs. 6. Tests: add NODE_OPTIONS=--experimental-vm-modules for Jest, or move to Vitest. 7. Search for subclasses that redeclare a constructor and add @Optional() where needed. 8. Run the app and hit every route, especially if you use NATS, GraphQL subscriptions, Terminus or custom pipes. 9. Optionally turn on routeConflictPolicy and routeResolutionStrategy: 'specificity', write new endpoints with Zod, and set NODE_COMPILE_CACHE in production. On AWS Lambda's Node 20, 22 and 24 runtimes, require(esm) is off by default. Set NODE_OPTIONS=--experimental-require-module there for a CommonJS app. Common mistakes when upgrading to NestJS 12 - Installing typescript@latest. That's 7.0, and the Nest CLI refuses to build with it. Pin ^6 until TypeScript 7.1. - Upgrading only the global CLI. The global binary hands off to the project's local v11 CLI, which doesn't know upgrade. - Trusting the "Node 24.9+" Jest note. On Node 24.22 and 26.12, Jest 30 still needed --experimental-vm-modules to load @nestjs/testing. - Fixing only tsconfig.build.json. Tests compile with tsconfig.json, which needs rootDir too. - Converting to ESM and keeping import * as X for CommonJS packages. It compiles and then crashes at startup. - Missing redeclared @Optional() markers. A subclass that redeclares its constructor now throws UnknownDependenciesException instead of injecting undefined, and 12.0.0 to 12.0.4 also throw for subclasses without a constructor. Use 12.1.x and re-mark optional parameters. - Rewriting every DTO to Zod at once. Both pipes run side by side. Migrate one controller at a time. Related: if you're choosing a database layer for your Nest API, read Drizzle vs Prisma in 2026. If you're exposing the API to AI agents, read MCP vs API. Related: NestJS on the Fastify adapter was 1.45 to 1.66× faster than on Express in my Hono vs Express vs Fastify benchmark. And since nest new now scaffolds Oxlint, see Oxlint vs Biome vs ESLint: Oxlint ran this site's ESLint rules 81× faster. Key takeaways - NestJS 12 shipped on August 27, 2026, with ESM-only packages, Standard Schema validation, a rebuilt CLI and @nestjs/observe. 12.1.3 is current. - nest upgrade --dry-run gives an accurate report, but upgrade the local CLI together with typescript@^6 or npm fails with ERESOLVE. - After the upgrade, fix TypeScript 6's rootDir (in both tsconfigs), baseUrl and strict DTO errors, and give Jest --experimental-vm-modules. - Throughput stayed within 5% of v11. Cold start grew by about 90 ms, and NODE_COMPILE_CACHE or Bun wins most of it back. - Zod through @Body({ schema }) was 28% faster than class-validator, 20,804 vs 16,237 req/s, and both pipes can run in one app. - Vitest ran the same e2e suite 40% faster than Jest on v12, and oxlint linted 5.5 times faster than ESLint. - TypeScript 7 can type-check and build a Nest 12 app with plain tsc, but nest build needs TypeScript 6 until 7.1. --- Originally published on fabwaseem.com.

  7. YouTube · · YouTube description · Insight AI · nestjs/nest

    N8N: Fair-code workflow automation platform, Neovim, Supabase | GitHub Curated Picks | 2026-10-15 GitHub Curated Picks reviews 11 source-verified repositories selected for developer usefulness, not popularity position. Each pick is checked against its GitHub repository, README, release evidence, and recent commit; pick numbers are editorial order. The compact card keeps rank, signal, why it matters, and next check together while the ledger preserves direct URLs and release evidence. Treat the list as an educational review surface, not software endorsement, and verify license and maintenance before adoption. Chapters: 00:00 2026-10-15 | GitHub Curated Picks 00:05 Pick #1 N8N 00:33 Pick #2 Neovim 00:48 Pick #3 Supabase 01:03 Pick #4 Ruff 01:13 Pick #5 Nest 01:27 Pick #6 Compose 01:40 Pick #7 Transformers 01:56 Pick #8 Svelte 02:08 Pick #9 Paperless Ngx 02:23 Pick #10 Sqlfluff 02:37 Pick #11 Megatron LM Source ledger: github.com/trending api.github.com github.com/n8n-io/n8n/blob/master/README.md github.com/n8n-io/n8n/commit/50c4ced2241430e13fa7… github.com/n8n-io/n8n/releases/tag/stable api.github.com github.com/neovim/neovim/blob/master/README.md github.com/neovim/neovim/commit/f505b0636e0e0010f… api.github.com github.com/supabase/supabase/blob/master/README.md github.com/supabase/supabase/commit/4215b6ca32a3b… api.github.com github.com/astral-sh/ruff/blob/main/README.md github.com/astral-sh/ruff/commit/83a3afc3156d73ae… api.github.com github.com/nestjs/nest/blob/master/Readme.md github.com/nestjs/nest/commit/b461186f20c245d5238… api.github.com github.com/docker/compose/blob/main/README.md github.com/docker/compose/commit/89e818966a5c2110… api.github.com huggingface/transformers github.com/huggingface/transformers/commit/e24eb5… api.github.com github.com/sveltejs/svelte/blob/main/README.md github.com/sveltejs/svelte/commit/79e7c3bd346bec0… api.github.com paperless-ngx/paperless-ngx github.com/paperless-ngx/paperless-ngx/commit/14a… api.github.com sqlfluff/sqlfluff github.com/sqlfluff/sqlfluff/commit/8b68456e30eec… api.github.com NVIDIA/Megatron-LM github.com/NVIDIA/Megatron-LM/commit/9131593673d0… n8n-io/n8n neovim/neovim supabase/supabase astral-sh/ruff nestjs/nest docker/compose sveltejs/svelte Informational and educational purposes only. Not investment advice, product endorsement, or professional guidance.

    14 views
  8. I've contributed to @nestframework Idempotency by making lock TTL configurable and fixing flaky tests. Thanks to NestJS creator @kammysliwiec idempotency#28 t.co

    11 likes · 179 views
  9. @MrBye32 Here're the reproduction steps and code for all libs/frameworks: nest#18063

    10 likes · 1,161 views
  10. @saltyAom Reproduction steps and code for all libs/frameworks: nest#18063

    13 likes · 1,135 views

Achievements

Awards earned by the repositories nestjs owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.