TypeScript 6 shipped, and I picked up the upgrade in our Vite + NX monorepo (8 projects, ~5,300 .ts/.tsx files) the same week it landed. The package.json change was a single line — typescript@5.9.2 to 6.0.3 — but the upgrade wasn't a no-op. TS 6 deprecates moduleResolution: "node" and tightens rootDir enforcement, and both broke the workspace's type-check the moment I bumped the dependency. Most of the PR was the two tsconfig fixes needed to get green again: flipping moduleResolution to "bundler" and replacing baseUrl with explicit rootDirs on each app. I also dropped the vite-tsconfig-paths plugin in favor of Vite 8.0.13's built-in resolve.tsconfigPaths while I was already in the resolver layer — that one was optional, the other two weren't.
This is the post about that migration. The next one — moving the same workspace from tsc to tsgo — followed a day later and assumes this one already happened.
What Actually Changed
The TypeScript-relevant pieces of the diff are short. Routine version bumps for the rest of the toolchain (Vite, NX, Vitest, lint/format, etc.) rode along in the same PR but aren't the interesting story here. The TS-specific changes:
typescript5.9.2to6.0.3- Removed:
vite-tsconfig-paths(replaced by Vite's built-inresolve.tsconfigPaths) - Removed:
vite-plugin-dts(we don't need it in this workspace)
Plus the tsconfig fixes TS 6 forced: moduleResolution: "bundler" (the old "node" mode is deprecated under TS 6 and started throwing module-resolution errors), dropping baseUrl in favor of explicit rootDirs (TS 6 enforces rootDir boundaries more strictly than 5.9 did), and a couple of small flag adjustments.
Dropping vite-tsconfig-paths
For years the standard recipe for "make Vite respect my tsconfig.json paths" was vite-tsconfig-paths. It reads your paths field and tells Vite's resolver how to handle @workspace/core/* imports and friends. It worked fine, but it was one more plugin to install, configure, and keep in sync with Vite's resolver internals.
Vite 8.0.13 makes it redundant. There's now a built-in option — resolve.tsconfigPaths: true — that wires tsconfig.json's paths into the bundler's resolver directly. Same behavior, no plugin.
Here's what apps/web-app/vite.config.mts looked like before:
import react from '@vitejs/plugin-react';
import { config as dotenvConfig } from 'dotenv';
import { defineConfig } from 'vite';
import graphqlLoader from 'vite-plugin-graphql-loader';
import svgr from 'vite-plugin-svgr';
import viteTsconfigPaths from 'vite-tsconfig-paths';
dotenvConfig();
export default defineConfig({
// ...
plugins: [
svgr({ include: '**/*.svg?react' }),
react(),
viteTsconfigPaths(),
graphqlLoader(),
],
});And after:
import react from '@vitejs/plugin-react';
import { config as dotenvConfig } from 'dotenv';
import { defineConfig } from 'vite';
import graphqlLoader from 'vite-plugin-graphql-loader';
import svgr from 'vite-plugin-svgr';
dotenvConfig();
export default defineConfig({
// ...
resolve: {
tsconfigPaths: true,
},
plugins: [
svgr({ include: '**/*.svg?react' }),
react(),
graphqlLoader(),
],
});One import removed, one plugin removed, one resolve block added. The same change applies everywhere we had the plugin — every app config (web-app, id-client, api-gateway), the storybook vite.config.ts, and the storybook main.ts's viteFinal override.
While I was in there, I also removed vite-plugin-dts from every library package's vite.config.ts (core, graphql, editor, ui, assets, components). The dts plugin generates .d.ts files alongside the bundled JS — useful if you publish the package to npm, pointless if every consumer is inside the same monorepo and imports the source via paths. Our libraries are all consumed in-repo, so the emitted declarations were just sitting in dist/ unused.
moduleResolution: "bundler"
This was the first thing that broke when I bumped to TS 6. We were on moduleResolution: "node" — the Node 10-era CommonJS resolution mode — for historical reasons. TS 6 deprecates "node" in favor of "node16", "nodenext", and "bundler", and starts surfacing Cannot find module '...' or its corresponding type declarations. errors for packages whose exports map the old resolver was quietly ignoring.
For a Vite monorepo the right choice is "bundler", which TypeScript recommends for projects built by a bundler — it understands package.json exports / imports fields, doesn't require explicit file extensions, and matches how Vite, esbuild, and Rolldown actually find modules at build time. The fix in tsconfig.base.json was one line:
{
"compilerOptions": {
"resolveJsonModule": true,
"moduleResolution": "bundler"
// ...
}
}After the flip, the compiler and the bundler finally agree on how to resolve @scope/pkg/subpath — the "works in build, breaks in type-check" papercuts we'd been collecting under "node" all stopped at the same time.
There's also a small follow-up in apps/id-client/tsconfig.node.json, which was still pinned to "node" for its Vite config file — same flip, same reason:
{
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "bundler"
},
"include": ["vite.config.ts", "vite.config.noexternal.ts"]
}baseUrl to rootDir
This was the other forced fix. Under TS 5.9, libraries that imported source from sibling packages via paths could get away with vague rootDir settings — tsc would resolve the file and move on. TS 6 doesn't: any source file resolved through paths that lives outside the project's rootDir becomes a hard error, not a warning. So even with moduleResolution flipped, the build stayed broken until the baseUrl / paths setup was untangled.
The old tsconfig.base.json had:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@workspace/core/*": ["packages/core/src/*"],
"@workspace/utils/*": ["packages/utils/src/*"],
"@workspace/ui/*": ["packages/ui/src/*"],
"@workspace/components/*": ["packages/components/src/*"],
"@workspace/assets/*": ["packages/assets/src/*"],
"@workspace/editor": ["packages/editor/src/index.ts"],
"@workspace/editor/*": ["packages/editor/src/*"],
"@workspace/graphql/*": ["packages/graphql/src/*"]
}
}
}baseUrl: "." was doing two jobs: making paths resolve relative to the workspace root, and (separately) allowing bare-specifier imports like import x from "packages/core/src/x" to resolve. The first job has been deprecated for a while — TypeScript recommends explicit relative paths instead. The second was something we were never relying on intentionally.
So baseUrl came out, and every paths entry got an explicit ./ prefix:
{
"compilerOptions": {
"paths": {
"@workspace/core/*": ["./packages/core/src/*"],
"@workspace/utils/*": ["./packages/utils/src/*"],
"@workspace/ui/*": ["./packages/ui/src/*"],
"@workspace/components/*": ["./packages/components/src/*"],
"@workspace/assets/*": ["./packages/assets/src/*"],
"@workspace/editor": ["./packages/editor/src/index.ts"],
"@workspace/editor/*": ["./packages/editor/src/*"],
"@workspace/graphql/*": ["./packages/graphql/src/*"]
}
}
}The corresponding change in each app's tsconfig.app.json was to replace baseUrl: "." with rootDir: "../../":
// apps/web-app/tsconfig.app.json
{
"compilerOptions": {
"sourceMap": false,
"rootDir": "../../",
"paths": {
"@workspace/core/*": ["../../packages/core/src/*"],
// ...
}
}
}rootDir at ../../ tells the compiler "any source file inside the monorepo is fair game" — which is what you want when an app pulls in .ts files from sibling packages through paths. Without it, TypeScript will (under stricter resolution) complain that the path-resolved source file lives outside the project's root.
This is the same rootDir fix I wrote about in the tsgo post, but it actually landed here, in the TS 6 upgrade. tsgo enforces the rule strictly enough that you can't skip the cleanup, but TS 6 with moduleResolution: "bundler" also gets stricter than the old setup, so this is where the work belongs.
One-Line Additions Worth Noting
A couple of small flags went into tsconfig.base.json alongside the larger changes:
{
"compilerOptions": {
"noUncheckedSideEffectImports": false
}
}noUncheckedSideEffectImports is a TS 5.6+ flag that errors on import "./styles.css"-style imports if the module can't be resolved. It defaults to true under TS 6's stricter posture. We set it explicitly to false because a lot of our code uses side-effect imports for CSS, SVG, and graphql files that don't have ambient type declarations everywhere. Flipping it on is a separate piece of work; for the upgrade itself, we just wanted to preserve old behavior.
Gotchas
A few things I had to chase down during the rollout:
vite-tsconfig-paths was permissive about baseUrl. The plugin happily resolved @workspace/core/* against paths: { "@workspace/core/*": ["packages/core/src/*"] } even though, strictly, that path is interpreted relative to baseUrl. Vite's built-in resolver is stricter — once you remove baseUrl, every paths entry has to be either an explicit relative path (./packages/...) or an absolute one. If you flip these in the wrong order — drop baseUrl before rewriting paths — your dev server will start failing to resolve imports.
moduleResolution: "bundler" will surface deep imports that weren't declared in exports. A handful of transitive dependencies in our tree expose internals via deep import paths (some-pkg/lib/internal/x) but don't list those paths in their package.json exports. "node" resolution was lenient; "bundler" matches what bundlers actually do, which is to honor exports. The fix was either to bump the dependency to a version with proper exports or to add a narrow paths shim pointing at the file we actually wanted.
Removing vite-plugin-dts is monorepo-specific. It's only safe when your library packages are consumed as source via tsconfig paths — that is, when nothing outside the workspace imports them. If you publish a package to npm or even to a private registry, you still need either vite-plugin-dts or a tsc --build pass to emit declarations. We don't, so out it went.
Storybook has its own Vite config. Easy to forget: apps/storybook/.storybook/main.ts does a mergeConfig inside viteFinal that was also adding tsconfigPaths() as a plugin. That needed the same treatment — drop the plugin import, replace it with resolve: { tsconfigPaths: true } inside the merged config. Otherwise storybook silently keeps the old plugin behavior and you have two different resolution paths in the same workspace.
Conclusion
The TypeScript 6 upgrade wasn't a one-line bump in practice — it forced two real tsconfig fixes (moduleResolution: "bundler" and baseUrl → rootDir) before the workspace would type-check again. But those changes were good ideas on their own merits, and the workspace is in a much tidier state for them: one less Vite plugin (vite-tsconfig-paths), no more baseUrl, and a resolver mode that finally matches the bundler.
The natural follow-up — and the reason this upgrade was a prerequisite — is moving the same workspace from tsc to tsgo (the TypeScript 7 native preview). With paths, rootDir, and moduleResolution all in a tidy state, that migration becomes much smaller than it would have been otherwise.
Disclaimer: Claude/AI was used to help write this blog post. The exact changes described reflect a private monorepo's particular layout; some details (especially around vite-plugin-dts and noUncheckedSideEffectImports) will be different in your codebase.