How a Single Line in `tsconfig.json` Broke My NestJS Build (and How I Found How To Fix It)
RETOUR AU BLOG
Tutorials

12 avril 2026
8 min read

The Error

You run npm run start:dev, TypeScript compiles cleanly — zero errors — and then Node crashes:

[9:13:16 PM] Starting compilation in watch mode...
[9:13:19 PM] Found 0 errors. Watching for file changes.

Error: Cannot find module '/path/to/backend/dist/main'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1212:15)

Zero TypeScript errors. Yet the server won't start. What's going on?


Background: How NestJS CLI v11 Starts Your App

In older versions of the NestJS CLI, nest start --watch used ts-node under the hood. ts-node compiles TypeScript on-the-fly in memory — it never writes .js files to disk. The dist/ folder was irrelevant for development.

NestJS CLI v11 changed this. Without @swc/core installed, it now defaults to a tsc watch + node pipeline:

1. Run tsc --watch → compile TypeScript and write .js files to dist/

2. When compilation finishes, run node dist/main.js

3. On file changes, recompile and restart

This means dist/main.js must exist at exactly the right path. If it doesn't — even if there are zero TypeScript errors — the process crashes.


The Root Cause: TypeScript's rootDir Inference

TypeScript has a compiler option called rootDir. It controls the output directory structure — specifically, it defines the "root" from which all input file paths are computed when writing to outDir.

You almost never set rootDir explicitly. TypeScript infers it automatically from your include array:

TypeScript sets rootDir to the longest common ancestor path of all input files.

This is the key insight. With a typical backend-only setup:

{
  "compilerOptions": {
    "outDir": "./dist"
  },
  "include": ["src"]
}

All input files are under backend/src/. The common ancestor is src/. So:

src/main.ts       →  dist/main.js        ✓
src/app.module.ts →  dist/app.module.js  ✓

NestJS CLI looks for dist/main.js — and it's there.

What Happens When You Add a Directory Outside the Project

Now say you add a shared module directory — maybe some types or utilities you want to share between the backend and another service. You add it to include:

{
  "include": ["src", "../shared"]
}

TypeScript now sees input files in two locations:

  • backend/src/*.ts
  • shared/*.ts (one level up)

The longest common ancestor of backend/src/ and shared/ is the parent directory — the monorepo root.

So rootDir silently expands to ../ (the parent), and the output becomes:

backend/src/main.ts  →  dist/backend/src/main.js  ✗
shared/contract.ts   →  dist/shared/contract.js

NestJS CLI still tries to run node dist/main.js. That file doesn't exist. The app crashes — with zero TypeScript errors, because the TypeScript part succeeded perfectly.


Why This Is So Confusing

Three things conspire to make this maddening to debug:

1. TypeScript reports zero errors. The compilation genuinely succeeds. The output structure is wrong, not the TypeScript. 2. The error says "module not found" — which looks like a runtime import problem, not a build configuration issue. You'll waste time checking your module names. 3. The previous behavior was different. If you were using ts-node before (older NestJS CLI), this never surfaced. The dist/ folder was never used. Everything worked. You only discover the breakage when you upgrade or when the CLI starts actually using the compiled output.

The Solution: TypeScript Project References

The right fix is TypeScript Project References. This is TypeScript's official solution for monorepo-style setups where multiple packages need to share code.

How Project References Work

Instead of pulling shared source files into your compilation (which expands rootDir), you tell TypeScript:

"That directory is a separate TypeScript project. Compile it independently, and use its compiled .d.ts output when type-checking my code."

TypeScript then uses the pre-compiled declaration files for type information and never adds the shared .ts files to the current project's input file list. rootDir is not affected.


Step 1: Create a tsconfig.json for the Shared Module

In your shared directory, add a tsconfig.json with composite: true:

{
  "compilerOptions": {
    "module": "commonjs",
    "moduleResolution": "node",
    "target": "ES2023",
    "outDir": ".",
    "rootDir": ".",
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["."],
  "exclude": ["<em></em>/*.js"]
}

Key settings:

  • composite: true — required for project references to work. It enforces that the project always emits .d.ts files and enables incremental rebuilds.
  • declaration: true — emits .d.ts type declaration files alongside the compiled .js.
  • outDir: "." and rootDir: "." — compile the .js and .d.ts files in-place, alongside the TypeScript source. This keeps the compiled runtime files at a predictable, stable location.

Step 2: Add References to the Backend tsconfig.json

In both tsconfig.json and tsconfig.build.json:

{
  "compilerOptions": { ... },
  "include": ["src"],
  "references": [{ "path": "../shared" }]
}

Two important changes:

  • Remove "../shared" from include — this is what caused the problem.
  • Add the references array — this tells TypeScript to use the shared project's compiled output instead of its source.
⚠️ references is not inherited via extends. You must add it to both tsconfig.json and tsconfig.build.json separately.

Step 3: Exclude prisma/seed.ts from the Build Config

You may have other directories in your include that contain .ts files outside src/. A common example is a prisma/ directory with a seed.ts file. This file widens rootDir from src/ to backend/, shifting the output from dist/main.js to dist/src/main.js — still wrong.

Add it to exclude in tsconfig.build.json:

{
  "extends": "./tsconfig.json",
  "exclude": ["node_modules", "test", "dist", "<em></em>/*spec.ts", "prisma"]
}

The seed file is typically run with ts-node directly, not compiled by the main build. Excluding it from the build tsconfig is correct.


Step 4: Handle Runtime Alias Resolution

TypeScript paths aliases (like @contract, @engine) are a compile-time type-checking feature only. The TypeScript compiler does not rewrite require('@contract') to a relative path in the compiled output. After tsc runs, your .js files still contain require('@contract'), which Node.js cannot resolve on its own.

tsconfig-paths is the standard solution. It reads your tsconfig.json at runtime and registers a custom module resolver that handles the aliases.

Add it as the very first import in main.ts:

import 'tsconfig-paths/register';
import { NestFactory } from '@nestjs/core';
// ... rest of imports

It must be first — before any other import that might trigger alias resolution.

For this to work at runtime, the shared module's .js files must actually exist on disk — which is exactly what the in-place compilation in Step 1 provides.


Step 5: Add Pre-Build and Pre-Start Scripts

The shared files must be compiled before the backend starts or builds. Add lifecycle scripts to package.json:

{
  "scripts": {
    "prebuild": "tsc -p ../shared/tsconfig.json",
    "build": "prisma generate && nest build",
    "postbuild": "tsc-alias -p tsconfig.build.json",
    "prestart": "tsc -p ../shared/tsconfig.json",
    "start": "nest start",
    "prestart:dev": "tsc -p ../shared/tsconfig.json",
    "start:dev": "nest start --watch",
    "start:prod": "node dist/main"
  }
}

npm automatically runs pre* scripts before their corresponding commands. So npm run start:dev will compile the shared module first, then start the NestJS watcher.

Also note: start:prod is now node dist/main (not dist/src/main), reflecting the corrected output path.


Step 6: Gitignore the Compiled Shared Artifacts

The .js and .d.ts files in the shared directory are generated artifacts. Add a .gitignore to the shared directory:

*.js
*.d.ts
*.d.ts.map
tsconfig.tsbuildinfo

This keeps the repository clean. The files are regenerated by the prebuild/prestart scripts on every developer machine and in CI.


The Full Picture

Here is how everything fits together after the fix:

npm run start:dev
  │
  ├─ prestart:dev: tsc -p ../shared/tsconfig.json
  │    └─ Compiles shared/contract.ts → shared/contract.js + shared/contract.d.ts
  │
  └─ nest start --watch
       │
       ├─ tsc --watch (uses tsconfig.build.json)
       │    ├─ Sees "references: ../shared" → uses shared/contract.d.ts for types
       │    ├─ rootDir = src/ (only src/ files in the compilation)
       │    └─ Outputs: dist/main.js  ✓
       │
       └─ node dist/main.js
            ├─ require('tsconfig-paths/register')  ← first line of main.ts
            │    └─ registers @contract → ../shared/contract.js resolver
            └─ rest of app boots normally  ✓

Summary of Changes

| File | Change | Why |

|---|---|---|

| shared/tsconfig.json | Created with composite: true | Enables project references |

| shared/.gitignore | Created | Excludes compiled artifacts from git |

| backend/tsconfig.json | Removed ../shared from include, added references | Prevents rootDir expansion |

| backend/tsconfig.build.json | Added prisma to exclude, added references | Prevents seed.ts from widening rootDir |

| backend/src/main.ts | Added import 'tsconfig-paths/register' as first line | Runtime alias resolution |

| backend/package.json | Added pre* scripts, fixed start:prod path | Compile shared before start/build |


Key Takeaways

  • TypeScript's rootDir is inferred automatically from the common ancestor of all input files. Adding a directory outside your project to include silently shifts every output path.
  • NestJS CLI v11 uses tsc + node (not ts-node) for watch mode when SWC is not installed. The dist/ output path now matters for development, not just production.
  • TypeScript paths aliases are type-checking only. Runtime resolution requires a separate mechanism — tsconfig-paths/register is the most common solution.
  • Project References are the right tool for sharing TypeScript code between packages in a monorepo. They give you type safety across packages without merging the compilations.
  • references is not inherited via extends. You must add it explicitly to every tsconfig that needs it.