NestJS 12 Upgrade Gotchas: ESM Resolution, Standard Schema Adoption, and Observability Hooks

NestJS 12 Upgrade Gotchas: ESM Resolution, Standard Schema Adoption, and Observability Hooks

pr0h0•
nestjstypescriptesmobservabilityvalidation
AI Usage (80%)

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

ClaimStatusWhy it matters
ESM packages shipReportedDecides whether your CJS build still resolves
Standard Schema supportReportedDecides whether your DTO layer can leave classes behind
Native observability hooksReportedDecides whether a third-party interceptor gets retired
Dual (CJS + ESM) or ESM-only publicationUnknownBiggest single factor in migration cost
Minimum supported Node versionUnknownDetermines whether require(esm) is even on the table
Exact hook names and payload shapeUnknownDon't design against an API I haven't seen
Whether ValidationPipe accepts a schema directlyUnknownDecides 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:

  1. types goes first. TypeScript resolves conditions in the same order Node does, so a types entry after import is a known footgun.
  2. default goes 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.service has to become ./users.service.js in ESM output. Mechanical, but it touches every file. A codemod handles it; hand-editing a 400-file repo does not.
  • verbatimModuleSyntax, which forces import type wherever 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:

  • __dirname and require in your own code. Neither exists in ESM scope. Rebuild them with import.meta.url and createRequire.
  • Decorator metadata with type-only imports. This is the one that bites hardest. emitDecoratorMetadata needs a runtime reference to the class to emit design:paramtypes. Under verbatimModuleSyntax, marking an injected service as import type degrades 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-jest with the default CJS preset, the module transform config is the deciding factor.
  • Dynamic require in 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-validator produces ValidationError[] with constraints maps; Standard Schema produces issues with path. 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.
  • whitelist and forbidNonWhitelisted. These are ValidationPipe options tied to class-validator internals, with no obvious schema-side analogue.
  • OpenAPI generation. @nestjs/swagger reads decorator metadata, and its CLI plugin transforms DTO classes. Schema-based DTOs likely need createDocument from 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 EventEmitter boundaries
  • 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

  1. Pin Node. Confirm require(esm) availability or commit to full ESM.
  2. Move tsconfig to nodenext, fix imports, keep emitDecoratorMetadata honest.
  3. Run attw --pack on your own build, not just the framework's.
  4. Fix the test runner. Nothing after this step is trustworthy if the tests are lying.
  5. Boot the app and hit one endpoint per module.
  6. Then — and only then — touch validation.
  7. Observability last, once you've decided who owns the span.

NestJS 12 Upgrade Gotcha Table By Layer

LayerFailureDetected by
BuildExtension-less relative importstsc
PackageBad exports condition orderattw --pack
RuntimeTwo module instances of @nestjs/coreDuplicate provider errors at boot
DIimport type erasing decorator metadata"Nest can't resolve dependencies"
TestsCJS transform preset on ESM sourceJest ESM errors
ValidationError shape mismatch in the filterIntegration test on 400 response
TracingDuplicate server spansSpan 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

Share this post

More posts

Comments