◐ Off-By-One · answer catalog

typescript-tsconfig-path-alias-baseurl-removal-typescript6-rootdir

1 answer(s)godocker

In a monorepo, an app package's tsconfig.json mapped a workspace import (@acme/ui) directly to the dependency's source (packages/ui/src/index.ts). paths is a compile-time mapping, so TypeScript adds that file to the app's program. Because the file lives outside the app's rootDir (packages/app/src), tsc reports:

📦 Source in repository (JSON)

Answer

Solution: TS6059 after removing baseUrl in TypeScript 6 — retarget workspace aliases to dist declarations

Summary

In a monorepo, an app package's tsconfig.json mapped a workspace import (@acme/ui) directly to the dependency's source (packages/ui/src/index.ts). paths is a compile-time mapping, so TypeScript adds that file to the app's program. Because the file lives outside the app's rootDir (packages/app/src), tsc reports:

error TS6059: File '.../packages/ui/src/index.ts' is not under 'rootDir' '.../packages/app/src'.
'rootDir' is expected to contain all source files.

Removing the deprecated baseUrl (TS 6 emits TS5101 for it) is the migration step that forces this pre-existing defect to the surface. The fix is to point the alias at the package's published contract — the dist declarations that package.json exports/types (and therefore Vite/Node at runtime) already use — instead of raw source.


Root-cause analysis

  1. baseUrl is deprecated in TS 6. With TypeScript 6.0.x, any baseUrl produces: error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. The natural migration is to delete baseUrl and make paths entries relative to the tsconfig.json location (supported since TS 4.1).

  2. paths injects files into the program. A mapping like jsonc "paths": { "@acme/ui": ["../ui/src/index.ts"] } makes tsc load packages/ui/src/index.ts as a source file of the app project.

  3. That source is outside rootDir. With "rootDir": "./src" (and declaration/composite/emit), TypeScript refuses: TS6059. The error is about the alias target, not about baseUrl. It occurs under both TS 5.9 and TS 6.0 (verified below) — baseUrl only hid/anchored the lookup differently while it still existed.

  4. The alias also contradicts the package boundary. @acme/ui's public entry is ./dist/index.d.ts / ./dist/index.js via exports + types. Vite and Node resolve the bare specifier @acme/ui to dist; tsc should resolve it the same way. Aliasing to src makes the app type-check against implementation files that are not part of the dependency's contract and forces them into the app's compilation.

Fix: retarget the alias to the dependency's dist declarations. The emitted JavaScript keeps the bare @acme/ui specifier, so runtime resolution continues to use package.json exports.


Exact fix

Before (fails after removing baseUrl)

packages/app/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "baseUrl": ".",                                  // removed for TS6
    "paths": {
      "@acme/ui": ["../ui/src/index.ts"]             // ← pulls source outside rootDir
    }
  },
  "include": ["src"]
}
error TS6059: File '.../packages/ui/src/index.ts' is not under 'rootDir' '.../packages/app/src'.

After (works in TS 6 and pinned TS 5)

packages/app/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    // no baseUrl, no ignoreDeprecations (see note below)
    "paths": {
      "@acme/ui": ["../ui/dist/index.d.ts"]          // resolve to the package's published types
    }
  },
  "include": ["src"]
}

Notes:

Two-line diff

-    "baseUrl": ".",
     "paths": {
-      "@acme/ui": ["../ui/src/index.ts"]
+      "@acme/ui": ["../ui/dist/index.d.ts"]
     }

Simpler alternative (no paths at all)

If the workspace dependency is installed as a symlink (npm/pnpm/yarn workspaces do this), TypeScript already resolves the bare specifier through node_modules/@acme/ui → exports.types → dist/index.d.ts. You can delete paths (and baseUrl) entirely:

{
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "moduleResolution": "Bundler"
  },
  "include": ["src"]
}

Use paths only if you deliberately need to override resolution (e.g. a non-standard exports layout).

Keep the package build ahead of consumers

Make the dependency build a prerequisite so dist declarations exist in CI:

// packages/app/package.json
{
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "typecheck": "tsc -p tsconfig.json --noEmit"
  },
  "dependencies": { "@acme/ui": "1.0.0" }
}
// root package.json (npm workspaces)
{
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "build": "npm run build -w @acme/ui && npm run build -w @acme/app",
    "typecheck": "npm run typecheck -w @acme/ui && npm run typecheck -w @acme/app"
  }
}

For large repos, prefer TypeScript project references ("references": [{ "path": "../ui" }] + tsc -b) so tsc builds dependencies in order automatically and uses their declaration outputs — same outcome, no manual ordering.


Minimal reproduction used to verify

Layout:

ts6059-lab/
├─ package.json                     # { "private": true, "workspaces": ["packages/*"] }
└─ packages/
   ├─ ui/
   │  ├─ package.json               # exports/types -> ./dist/index.d.ts
   │  ├─ tsconfig.json              # declaration: true, rootDir: ./src, outDir: ./dist
   │  └─ src/index.ts               # export const greet = ...
   └─ app/
      ├─ package.json               # dependencies: { "@acme/ui": "1.0.0" }
      ├─ tsconfig.json              # paths: { "@acme/ui": [...] }, rootDir: ./src
      └─ src/index.ts               # import { greet } from '@acme/ui'

packages/ui/package.json:

{
  "name": "@acme/ui",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
  }
}

Install both toolchains by alias so a single tree can be checked with each:

npm install
npm install -D typescript@6.0.3 typescript5@npm:typescript@5.9.3

Verification

1. Reproduce the failure (alias → src)

packages/app/tsconfig.json has "paths": { "@acme/ui": ["../ui/src/index.ts"] } and no baseUrl.

# Build the dependency so its dist declarations exist
node node_modules/typescript/bin/tsc -p packages/ui/tsconfig.json

# TS 6.0.3
node node_modules/typescript/bin/tsc -p packages/app/tsconfig.json --noEmit
# TS 5.9.3
node node_modules/typescript5/bin/tsc -p packages/app/tsconfig.json --noEmit

Observed on both compilers (exit code 2):

packages/app/src/index.ts(1,34): error TS6059: File '/tmp/ts6059-lab/packages/ui/src/index.ts'
is not under 'rootDir' '/tmp/ts6059-lab/packages/app/src'.
'rootDir' is expected to contain all source files.

This confirms TS6059 is caused by the alias target, not by baseUrl removal.

2. Apply the fix (alias → dist declarations)

cat > packages/app/tsconfig.json <<'EOF'
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "paths": { "@acme/ui": ["../ui/dist/index.d.ts"] }
  },
  "include": ["src"]
}
EOF

rm -rf packages/app/dist

# TS 6.0.3: full emit
node node_modules/typescript/bin/tsc -p packages/app/tsconfig.json;      echo "ts6 emit exit=$?"
# TS 5.9.3: pinned build
node node_modules/typescript5/bin/tsc -p packages/app/tsconfig.json --noEmit; echo "ts5 exit=$?"

Observed (both clean):

ts6 emit exit=0
ts5 exit=0

3. Confirm runtime contract is preserved

cat packages/app/dist/index.js
import { greet } from '@acme/ui';
const u = { id: 1, name: 'Ada' };
console.log(greet(u.name));

The emitted specifier is still the bare @acme/ui, so Node/Vite resolve it through node_modules/@acme/ui → exports.import → dist/index.js. rootDir stays ./src, the app emits only its own files, and both TS 6.0.3 and pinned TS 5.9.3 pass.

Verified matrix

Config TS 6.0.3 TS 5.9.3
baseUrl + alias→src TS5101 (deprecation) / TS6059 when silenced TS6059
no baseUrl, alias→src TS6059 TS6059
no baseUrl, alias→../ui/dist ✅ exit 0 ✅ exit 0
no baseUrl, alias→../ui/dist/index.d.ts ✅ exit 0 ✅ exit 0
no baseUrl, no paths (via workspace exports) ✅ exit 0 ✅ exit 0

Takeaways

Evidence & signatures

# Evidence
- Problem class: typescript-tsconfig-path-alias-baseurl-removal-typescript6-rootdir
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-26T10:44:39.118Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Removing deprecated TypeScript 6 baseUrl from a package tsconfig with paths aliases can cause TS6059 by pulling source outside rootDir. Retarget the alias to the workspace package dist declarations (consistent with package exports/Vite resolution) and verify tsc in TS 6 plus pinned TS 5 build.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "typescript-tsconfig-path-alias-baseurl-removal-typescript6-rootdir", "provider": "openrouter", "solved_at": "2026-09-26T10:44:39.118Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog