
NestJS 12 Upgrade Gotchas: ESM Resolution, Standard Schema Adoption, and Observability Hooks
NestJS 12 Upgrade Gotchas: What This Post Covers
NestJS 12 reportedly ships three changes at once: ESM package output, Standard Schema validation support, and native observability hooks. This post walks through the upgrade gotchas that can break an existing TypeScript app, separates confirmed facts from inference, and gives you checks to run before you merge. They are not equally dangerous, and folding them into one upgrade PR is how a team ends up rolling back for two weeks.
My position: the ESM output is the only change here that can break an app which isn't using any of the new features. Standard Schema adoption has the largest long-term payoff and the murkiest migration path. Observability hooks are the cheap win — take them early and don't overthink them.
One caveat shapes everything below: my source is a release report, not the changelog. I have the headline claims and nothing verified behind them. So I'll keep what the report says separate from what I'm inferring out of how Node, TypeScript, and Nest actually behave, and I'll give you checks to run in your own repo instead of quoting results I can't reproduce.
What The Release Report Confirms And What It Doesn't
The Three NestJS 12 Changes Worth Acting On
The report states that NestJS 12 ships ESM packages, adds Standard Schema validation support, and introduces native observability features. Those three map onto three different migration problems:
- ESM package output changes the shape of everything
@nestjs/*publishes. That's packaging, and it reaches every consumer whether or not you import anything new. - Standard Schema validation changes who owns the validation contract — the library or the framework adapter.
- Native observability hooks add a first-party instrumentation seam to the request lifecycle.
The first is a compatibility problem. The second is an architecture decision. The third is a feature.
Confirmed Facts, Inference, And The Unknowns In The Release Report
| Claim | Status | Why it matters |
|---|---|---|
| ESM packages ship | Reported | Decides whether your CJS build still resolves |
| Standard Schema support | Reported | Decides whether your DTO layer can leave classes behind |
| Native observability hooks | Reported | Decides whether a third-party interceptor gets retired |
| Dual (CJS + ESM) or ESM-only publication | Unknown | Biggest single factor in migration cost |
| Minimum supported Node version | Unknown | Determines whether require(esm) is even on the table |
| Exact hook names and payload shape | Unknown | Don't design against an API I haven't seen |
Whether ValidationPipe accepts a schema directly | Unknown | Decides whether you need an adapter class |
I'm not going to invent a minimum Node version, a list of removed APIs, or an error string to make this read as authoritative. Run npm view @nestjs/common@12 type exports engines before you plan anything — those four fields answer half that table.
ESM Package Output Is The NestJS 12 Breaking Change To Plan For
ESM Resolution: Conditional Export Order And The Dual-Package Hazard
If NestJS 12 publishes dual output, the exports map becomes a contract with order-sensitive condition matching. Node walks the conditions object in insertion order and takes the first hit. Get it wrong and the package resolves differently under import than under require.
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
Two rules here aren't stylistic:
typesgoes first. TypeScript resolves conditions in the same order Node does, so atypesentry afterimportis a known footgun.defaultgoes last. Anything after it is dead.
The dual-package hazard follows from that. If your app loads @nestjs/core through require in one place and import in another, you can end up with two module instances. For a framework holding module-level state — reflected metadata, DI registries, reflect-metadata polyfill state — two instances is not a theoretical concern. The Node.js packages documentation describes the failure mode precisely. If NestJS 12 goes ESM-only instead, the hazard disappears and is replaced by "your CJS entrypoint no longer works directly."
TypeScript Settings That Break First Under ESM Resolution
The compiler shows the damage before the runtime does.
// before — the CommonJS-era defaults
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
}
}
// after — what actually resolves ESM condition maps
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"verbatimModuleSyntax": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
}
}
Switching to nodenext turns on two things that produce a wall of errors in a mid-sized Nest app:
- Extension-ful relative imports.
./users.servicehas to become./users.service.jsin ESM output. Mechanical, but it touches every file. A codemod handles it; hand-editing a 400-file repo does not. verbatimModuleSyntax, which forcesimport typewherever a symbol is only a type. That's correct behaviour, and the next section explains why it collides with decorators.
node16/nodenext resolution is documented in the TypeScript tsconfig reference; what matters is that it mirrors Node's semantics rather than approximating them.
ESM Interop Failures That Only Show Up At Runtime
The compiler won't catch any of these:
__dirnameandrequirein your own code. Neither exists in ESM scope. Rebuild them withimport.meta.urlandcreateRequire.- Decorator metadata with type-only imports. This is the one that bites hardest.
emitDecoratorMetadataneeds a runtime reference to the class to emitdesign:paramtypes. UnderverbatimModuleSyntax, marking an injected service asimport typedegrades that metadata and Nest fails to resolve the dependency. The fix is a value import, which looks wrong to a linter that's been told type imports are good. - Test runners. Jest's ESM support is real but config-heavy. On
ts-jestwith the default CJS preset, the module transform config is the deciding factor. - Dynamic
requirein module resolution paths. Anything that loads providers by string path at runtime needs a genuine look.
Verifying The ESM Module Switch In A Real NestJS Application
Reproducible ESM Checks Before You Merge
Three checks, in order, before anything merges:
## 1. What did the registry actually publish?
npm view @nestjs/common@latest type exports engines
## 2. Can your own build be loaded from both worlds?
node --input-type=module -e "import('./dist/index.js').then(m => console.log('esm ok', Object.keys(m).length))"
node -e "require('./dist/index.cjs'); console.log('cjs ok')"
## 3. Do the types resolve in every mode you ship to?
npx --yes @arethetypeswrong/cli --pack .
Are The Types Wrong is the tool I trust most here, because it checks resolution modes rather than compiling once and calling it done.
Reading Are The Types Wrong Output Instead Of Trusting The Docs
attw prints one row per resolution mode. Below is the shape of a report with fabricated rows, so you know what you're looking at:
## shape of the report — illustrative, not a run against a real package
node10 ✅
node16 (from CJS) 🚨 False ESM / CJS mismatch
node16 (from ESM) ✅
bundler ✅
A single 🚨 on node16 (from CJS) while node16 (from ESM) passes is the signature of a dual-package hazard or a wrong types condition — exactly the bug class that compiles clean and fails at boot. All ✅ on the modes you actually ship to is your merge gate.
Node's require(esm) support has improved a lot — recent 22.12+ and 20.19+ releases can require() synchronous ESM graphs — but check your own node --version rather than assuming. Pinned to an older patch, the answer may simply be no.
Standard Schema Adoption Changes Who Owns Validation In NestJS
The Standard Schema Interface And What ValidationPipe May Accept
Standard Schema is a spec, not a library. A validator conforms by exposing a ~standard property with version, vendor, validate, and types. Zod, Valibot, and ArkType have all moved toward it. The payoff: a consumer can validate against any conforming library without a per-library adapter.
That matters for Nest specifically, because the framework's validation story is currently welded to class-validator + class-transformer through decorators and class-transformer-driven coercion. If ValidationPipe accepts a conforming schema, library choice comes back.
I don't know what signature NestJS 12 exposes — whether ValidationPipe takes a schema directly or you write a thin adapter. Design for the adapter case; it's the safer assumption.
Coexisting With class-validator And class-transformer During Adoption
Don't attempt a big-bang conversion. The two models are structurally different: class-validator validates instances of classes you already have, and class-transformer mutates those instances into typed shapes, while a Standard Schema validator takes unknown input and returns a value or a list of issues.
The split I'd use: keep class-validator on existing DTOs, write new endpoints against a schema library, and run one adapter both paths feed into.
Where Standard Schema Interoperability Stops Being Free
These are the parts I'd expect to cost real time. All of it is inference from how the current validation layer behaves, and all of it deserves a spike before you commit:
- Error shape.
class-validatorproducesValidationError[]withconstraintsmaps; Standard Schema producesissueswithpath. Your exception filter and your API error contract are almost certainly shaped around the former. - Coercion and transformation.
@Type(() => Number)has no direct schema equivalent. Query-string coercion is where DTOs do the most implicit work. whitelistandforbidNonWhitelisted. These areValidationPipeoptions tied toclass-validatorinternals, with no obvious schema-side analogue.- OpenAPI generation.
@nestjs/swaggerreads decorator metadata, and its CLI plugin transforms DTO classes. Schema-based DTOs likely needcreateDocumentfrom the schema library instead. Untested — verify before you promise generated docs.
NestJS Observability Hooks Are The Least Risky Upgrade Change
What Native Observability Hooks Capture And What They Miss
A framework-level hook reliably gives you the request lifecycle: inbound request, handler invocation, exception, response. That's the layer Nest already owns and the layer your hand-rolled LoggingInterceptor currently fakes.
What it will not give you, judging by what a framework can actually observe:
- Database spans below the ORM boundary
- Context propagation across queues and
EventEmitterboundaries - Business attributes (
tenant.id,feature.flag) that only your handler knows about
Wiring Observability Hooks Without Duplicating Your Existing Tracer
The failure mode is double instrumentation: your hook emits a server span and OpenTelemetry's HTTP auto-instrumentation emits another for the same request. You get duplicated spans and inflated latency percentiles.
Pick one owner per layer. If you already run the OpenTelemetry JS SDK, suppress auto-instrumentation for the inbound HTTP layer and let the Nest hook own it — the hook sees handler boundaries better than a raw http patch does. If you have no tracer, take the hook and skip OTel for now.
A NestJS 12 Migration Order That Limits Blast Radius
- Pin Node. Confirm
require(esm)availability or commit to full ESM. - Move
tsconfigtonodenext, fix imports, keepemitDecoratorMetadatahonest. - Run
attw --packon your own build, not just the framework's. - Fix the test runner. Nothing after this step is trustworthy if the tests are lying.
- Boot the app and hit one endpoint per module.
- Then — and only then — touch validation.
- Observability last, once you've decided who owns the span.
NestJS 12 Upgrade Gotcha Table By Layer
| Layer | Failure | Detected by |
|---|---|---|
| Build | Extension-less relative imports | tsc |
| Package | Bad exports condition order | attw --pack |
| Runtime | Two module instances of @nestjs/core | Duplicate provider errors at boot |
| DI | import type erasing decorator metadata | "Nest can't resolve dependencies" |
| Tests | CJS transform preset on ESM source | Jest ESM errors |
| Validation | Error shape mismatch in the filter | Integration test on 400 response |
| Tracing | Duplicate server spans | Span count per request |
Where I Land On The NestJS 12 Upgrade
Take the observability hooks early — the risk is bounded and the payoff is immediate. Do the ESM migration as its own boring piece of work with attw as the gate, and don't let it ride along in a feature branch. Treat Standard Schema as a new-code decision for now: adopt it on endpoints you're writing fresh, and leave the class-validator DTOs alone until the error-shape and coercion questions have real answers.
The release isn't the risk. A combined upgrade PR is.
Further Reading
- Standard Schema specification — the
~standardinterface, version, and conformance rules - Node.js packages documentation — conditional exports and the dual-package hazard
- Node.js ESM documentation — resolution algorithm and
require(esm)behavior - TypeScript
moduleResolutionreference —node16/nodenextsemantics - Are The Types Wrong — resolution-mode checking tool
- NestJS validation docs — current
ValidationPipeandclass-validatorbehavior - OpenTelemetry JavaScript — auto-instrumentation and span ownership


