Skip to content

fix(monorepo): resolve ESM path aliases and project rootDir - #2444

Merged
kamilmysliwiec merged 10 commits into
nestjs:masterfrom
xia-chao:fix/monorepo-alias-esm
Sep 14, 2026
Merged

kamilmysliwiec merged 10 commits into
nestjs:masterfrom
xia-chao:fix/monorepo-alias-esm

Conversation

@xia-chao

@xia-chao xia-chao commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

PR Checklist

  • The commit message follows our guidelines
  • Tests for the changes have been added
  • Docs have been added / updated (the affected docs live in nestjs/docs.nestjs.com and need their own PR)

PR Type

[x] Bugfix

What is the current behavior?

Issue Number: N/A (see nestjs/nest#17706)

nest g library writes an alias that points at a folder:

"paths": { "@app/my-lib": ["./libs/my-lib/src"] }

Two things break on v12:

  • ESM: with moduleResolution: "nodenext", TS does not look for index.ts inside a folder, so the build fails with TS2307.
  • The generated project tsconfigs use composite: true + rootDir: "./src". Once the alias points at another project's source, TS reports TS6059 / TS6307. This also breaks CommonJS.

What is the new behavior?

  • ESM aliases point at the file: "@app/my-lib": ["./libs/my-lib/src/index.ts"]. CommonJS keeps the folder target.
  • Every generated project tsconfig drops composite and uses rootDir: "../.." — the sub-app, the library, and the workspace template used when nest g app moves the original app.
  • start:prod matches the builder in use: the nested node dist/apps/<app>/apps/<app>/src/main for tsc, the flat node dist/apps/<app>/main for bundlers.

Needs nestjs/nest-cli#3545 in the same release. On its own, neither side fixes the issue.

Does this PR introduce a breaking change?

[x] Yes

Only for builder: tsc: the entry moves from dist/apps/api/main.js to dist/apps/api/apps/api/src/main.js — the layout v11 produced under nest build, and the one nest start resolves automatically. Bundlers (rspack is the default) keep the flat dist/apps/api/main.js because they ignore rootDir, so their start:prod is unchanged.

Other information

Tested on generated v12 monorepos with #3545 applied and TypeScript 6.0.3:

  • ESM and CJS: nest build api exits 0 and boots.
  • The original app moved by nest g app builds and boots the same way.
  • nest build lib exits 0.
  • npm test: 42 files, 734 tests passing.

Alternative we considered

In the issue thread, an upgraded project avoids this by moving the aliases to node subpath imports — imports in package.json pointing at the built output. That works too and keeps the output flat, but every library has to be built first, and it changes the generated import style and the workflow. Keeping paths pointing at source needs one build step and matches the v11 layout, so this PR stays with that.

@xia-chao

xia-chao commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

In the issue thread, an older upgraded project avoids this by moving the aliases to node subpath
imports — imports in package.json pointing at the built output. That works too and keeps the
output flat, but every library has to be built first, and it changes the generated import style and
the workflow.

Keeping paths pointing at source needs one build step and matches what v11 produced under
nest build, so this PR stays with that.

@micalevisk micalevisk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough investigation on the issue, the approach here is the right one and the two templates you touched work. There is one more template that still produces the exact error this PR fixes, so requesting changes for that.

I verified by generating CJS and ESM monorepos with this branch (applicationsub-applibrary), replacing the sources with an app that imports @app/shared, and running TypeScript 6.0.3 (the version the templates pin) against each project config:

Project config CJS ESM
Sub-app, before this PR (composite + rootDir: ./src) TS6059 + TS6307 TS2307
Sub-app, this PR (rootDir: ../..) clean clean
Library, this PR clean clean
Original app moved by nest g app (workspace template, untouched) TS6059 + TS6307 TS6059 + TS6307
Root tsc -b tsconfig.json, this PR fails only on the original app fails only on the original app

Findings

  1. Blocking: src/lib/sub-app/workspace/ts/tsconfig.app.json still has the old config. It keeps composite: true and rootDir: "./src". That template is what the first nest g app writes for the original application when it moves it into apps/<name>/. That app is the one most likely to import a library, and with this PR it still fails with TS6059/TS6307 in both CJS and ESM. It needs the same change as the other two templates.

  2. The new test cannot catch (1). The project tsconfig test only reads the sub-app's and the library's tsconfig. Please also assert on the original app's config (in the test env it lands at /apps/nestjs-schematics/tsconfig.app.json, since the name comes from this repo's package.json).

  3. The start:prod script written by the sub-app schematic is now wrong for builder: tsc. updateNpmScripts in sub-app.factory.ts still writes node dist/apps/<app>/main, but with tsc the entry now emits to dist/apps/<app>/apps/<app>/src/main.js. With rspack (the default the schematic sets) the layout is unchanged, since rspack ignores rootDir, so the script stays correct there. Either adjust the script or state clearly that the layout change only applies to builder: tsc.

  4. Breaking-change scope is narrower than stated. Only builder: tsc sees a new output layout; rspack output stays at dist/apps/<app>/main.js. Worth saying so in the PR body / changelog. Also, "Docs have been added / updated" is ticked but the affected docs (node dist/apps/api/main) live in nestjs/docs.nestjs.com and need their own PR.

Verified as working

  • The ESM alias target ./libs/shared/src/index.ts resolves under nodenext; the CJS folder target keeps working.
  • nest start still finds the nested entry: nest-cli tries outDir/sourceRoot/entryFile first and falls back to outDir/entryFile.
  • Keeping references in the root tsconfig.json after dropping composite does not raise TS6306 on TS 6; tsc -b at the root accepts the non-composite projects.
  • The dependency on nestjs/nest-cli#3545 is real: without it the emitted specifier lacks index.js and Node fails at runtime even though tsc passes.
  • npm test: 42 files, 732 tests passing locally.

Comment thread src/lib/sub-app/files/ts/tsconfig.app.json
Comment thread test/lib/monorepo.test.ts
Comment thread src/lib/library/library.factory.ts
@xia-chao

xia-chao commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

All four points are addressed:

  • src/lib/sub-app/workspace/ts/tsconfig.app.json now gets the same change.
  • The test also asserts on /apps/nestjs-schematics/tsconfig.app.json.
  • start:prod is now written from the builder that is in effect once the CLI options are final: tsc gets the nested entry, bundlers keep the flat one. Both are covered by tests. (nest g app forces rspack when it converts a single app, so reading the builder earlier would have written a script that did not match the final config.)
  • The PR body now says the breaking change only affects builder: tsc, and the docs checkbox is unticked since those docs live in nestjs/docs.nestjs.com.

Also verified the moved original app end-to-end: nest build nestjs-schematics exits 0, the emitted specifier is ../../../libs/shared/src/index.js, and it boots.

@micalevisk micalevisk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! All four points from my previous review are addressed in e2e038d. I did a second pass focused on whether dropping composite: true is right, since it was added on purpose in v12.

Is dropping composite correct?

Yes. Going by the composite docs and the project references handbook, composite exists so tsc -b can find a referenced project's outputs. It does three things:

  1. It requires every implementation file to be matched by include/files. That rule is exactly TS6307 here: once an app pulls libs/*/src in through paths, its program contains files its include: ["src/**/*"] does not match. So changing rootDir alone does not fix it. With composite: true + rootDir: "../..", TS still reports TS6307 in both CJS and ESM.
  2. It defaults rootDir to the tsconfig directory. This no longer matters on TS 6, where "the default rootDir will always be the directory containing the tsconfig.json file". The explicit rootDir: "../.." is needed either way; without it you get TS5011.
  3. It forces declaration on. See the inline suggestion.

The handbook does say "referenced projects must have the new composite setting enabled". But tsc only enforces that (TS6306) when the referencing config has its own input files. The generated root tsconfig.json is solution-style ("files": [], which the handbook recommends), so references to non-composite projects are accepted. I checked that giving the root real inputs brings TS6306 back.

Nothing in the generated workspace uses what composite enables:

  • Apps don't reference libs, so there is no .d.ts redirection.
  • Libs are not referenced at all.
  • The CLI's tsc builder uses an incremental program, not build mode.

composite came in with the v12 release (#2302), and I found no stated reason for it. Keeping it would mean one of two things:

  • Widen every app's include to ../../libs/*/src/**/*. That works, but it compiles every lib into every app and has to follow custom library roots.
  • Turn libs into real referenced projects built first with tsc -b. That is a much bigger change, and it conflicts with bundlers resolving aliases to source.

Verification

Setup: TS 6.0.3, a generated applicationsub-applibrary workspace, both apps importing @app/shared.

Check master this PR
tsc -p each app (incl. the moved original app), CJS TS6059 + TS6307 clean
tsc -p each app, ESM TS2307 clean
tsc -p lib, CJS/ESM clean clean
tsc -p tsconfig.json (solution root) clean clean, no TS6306
tsc -b tsconfig.json, then again fails clean; 2nd run reports "up to date"
tsserver: owning project / diagnostics per file tsconfig.app.json, TS6059 + TS6307 tsconfig.app.json, none
composite: true + rootDir: "../.." n/a TS6307

Side effect worth mentioning: with rootDir: "../..", each project's .tsbuildinfo now lands inside its own outDir (dist/apps/admin/apps/admin/tsconfig.app.tsbuildinfo). On master, every app wrote to the same dist/apps/tsconfig.app.tsbuildinfo, outside its outDir, which is the nestjs/nest-cli#3408 situation.

npm test at e2e038d: 42 files, 734 tests passing.

Suggestions (none of them blocking)

  • declaration: false for app templates. With composite gone, apps no longer need declarations. See the inline suggestions.
  • Reuse the nest-cli config helpers in readBuilder. See the inline comment.
  • Tighten the start:prod regex. Small nit, inline.

Before merging / follow-ups

  • Ship with nestjs/nest-cli#3545. That PR is still open. Without it, ESM builds pass but crash at runtime, so this should only be released together with it.
  • Existing v12 projects. Workspaces already generated with 12.0.x keep composite + rootDir: "./src" and need the same manual change. That deserves a release note, or an upgrade-schematic hint in a follow-up.
  • Docs. docs.nestjs.com needs its own PR for the nested start:prod path under builder: tsc.

Comment thread src/lib/sub-app/files/ts/tsconfig.app.json Outdated
Comment thread src/lib/sub-app/workspace/ts/tsconfig.app.json Outdated
Comment thread src/lib/sub-app/sub-app.factory.ts
Comment thread src/lib/sub-app/sub-app.factory.ts Outdated
xia-chao and others added 6 commits September 14, 2026 01:50
Co-authored-by: Micael Levi L. Cavalcante <micalevisk@gmail.com>
Co-authored-by: Micael Levi L. Cavalcante <micalevisk@gmail.com>
Co-authored-by: Micael Levi L. Cavalcante <micalevisk@gmail.com>
`readBuilder()` walked every Nest CLI config file and took the first one that
declared a `builder`. The CLI only loads the first file it finds, so a
`.nestcli.json` builder could win over `nest-cli.json` and produce a wrong
`start:prod` script.

The config file list, `findNestCliConfigPath()` and `readJsonFile()` now live
in `src/utils/nest-cli-config.util.ts` and are shared by `sub-app.factory.ts`,
`library.factory.ts` and `upgrade.utils.ts` (re-exported so the existing
`upgrade.utils.js` consumers keep working). Covered by a test.
@xia-chao
xia-chao force-pushed the fix/monorepo-alias-esm branch from 2b29104 to 9f452c6 Compare September 13, 2026 17:51
kamilmysliwiec and others added 4 commits September 14, 2026 11:28
Pairing the project `rootDir` of `../..` with a per-project `outDir` of
`../../dist/<type>/<name>` made every output path repeat the project
segment: `dist/apps/api/apps/api/src/main.js`. The output path is
`outDir` plus the source path relative to `rootDir`, and `rootDir` has
to stay at the workspace root so a sibling project's sources resolved
through a path alias are inside the program. Explicitly setting
`rootDir` to `./src` instead brings back the `TS6059` this fixes, and
omitting it is an error in TypeScript 6 (`TS5011`).

Point `outDir` at `../../dist` instead. Projects then share the
workspace `dist` - which is where the rspack builder already writes,
since it sets no `output.path` - and the duplicated segment is gone:

  tsc      dist/apps/api/src/main.js, dist/libs/shared/src/index.js
  rspack   dist/apps/api/main.js

`deleteOutDir` resolves to that shared root, so `nest g app` drops it
when it converts a workspace to a monorepo; leaving it on would make
`nest build api` wipe every other project's output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`swc` mirrors the source tree the way `tsc` does - `stripLeadingPaths`
is off whenever `rootDir` sits above the source root, which it now
always is - so it emits `dist/<root>/<app>/src/main.js` and was being
handed the flat bundler entry. Branch on whether the builder bundles
instead, and cover `webpack` alongside `rspack`.

Two smaller mismatches with the CLI on the same path: `builder` may be
a `{ type }` object rather than a name, and when it is absent the CLI
assumes `tsc`, not the `rspack` the conversion happens to write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `rootDir`, `outDir` and path-alias values only matter through what
`tsc` does with them, and the failures they guard against - TS6059,
TS6307, TS2307 on an alias - are invisible to a JSON assertion. Add a
suite that writes a generated workspace to a temp directory, runs the
real compiler over each project and captures the emitted paths, for
both module systems.

`moveDefaultAppToApps` is a no-op under NODE_ENV=test, so the suite
performs the move itself; without it every check against the moved
workspace app compiles an empty program and passes for the wrong
reason.

Also fills in the schematic-level matrix: the builder forms and
fallbacks behind `start:prod`, `deleteOutDir` across repeated
generations, and the alias shapes per module system.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revert the shared workspace `dist` from the previous commit and put the
three project tsconfigs back to `outDir: ../../dist/<type>/<name>`,
with `deleteOutDir` left in place.

Run against the real CLI, the shared layout broke `nest start` under
the default builder. Bundlers write `dist/<root>/<entryFile>.js` no
matter what the tsconfig says, and `nest start` finds that file only
through its fallback probe, `<outDir>/<entryFile>` - which lines up with
the bundle solely when `outDir` is `dist/<root>`. With a shared `dist`
the probe became `dist/main.js` and the app failed to boot. A per-file
compiler mirrors the source tree under `rootDir` (the workspace root)
into that per-project `outDir`, so the nested
`dist/apps/<app>/apps/<app>/src/main.js` entry is forced by the two
constraints together, not a stray choice.

The builder handling from the previous commit stays: `swc` emits the
same nested layout as `tsc` (verified: `nest build` + `nest start` boot
from that path for tsc, swc and `{ type: 'swc' }`; rspack and webpack
boot from the flat bundle), `builder` may be a `{ type }` object, and
the CLI assumes `tsc` when it is absent.

The compile suite now pins both `nest start` probes against the
generated config, so the layout cannot drift from what the CLI looks
for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kamilmysliwiec
kamilmysliwiec merged commit 3a41c61 into nestjs:master Sep 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants