from the ../../../ maze to @/components
The paths option in tsconfig.json makes imports like @/components readable and stable, but it only solves type checking, not module resolution at runtime. This article shows how to carry aliases correctly all the way into Vite, Webpack, Node, and test runners, so no module-not-found error shows up after the build.
Table of Contents
- 1. The problem with deep relative imports (../../../ chains)
- 2. The "paths" option in tsconfig.json explained
- 3. baseUrl and how it interacts with paths
- 4. Why TypeScript alone does not rewrite imports at runtime
- 5. Matching bundler config: Vite resolve.alias
- 6. Matching bundler config: Webpack resolve.alias / tsconfig-paths
- 7. Matching the Node.js runtime: tsc-alias, tsconfig-paths/register, or subpath imports
- 8. Common pitfalls and debugging "Module not found"
- 9. Path mapping approaches compared
- 10. Summary
- 11. FAQ
1. The problem with deep relative imports (../../../ chains)
In grown TypeScript codebases, deeply nested modules almost inevitably end up importing each other through long relative paths like ../../../../components/ui/Button. Every time a file moves to a different folder level, these paths break, because the number of ../ segments must match the folder structure exactly. This makes code reviews needlessly hard to follow, since an import path like ../../../utils/format reveals nothing about the actual relationship between two modules, only about their arbitrary physical location on disk.
The real problem is structural: relative import paths couple code to its storage location instead of to its meaning. Refactorings that move entire directories often force hundreds of import path changes even though the underlying logic hasn't changed at all. On top of that, IDEs and merge tools frequently produce conflicts during parallel refactorings, because the same chains have to be adjusted in many files at once. An alias like @/components/ui/Button solves this problem, because it always looks the same regardless of where the importing file lives.
// Before: fragile relative import chain that breaks on every file move
import { Button } from '../../../../components/ui/Button';
import { formatCurrency } from '../../../utils/format';
import { useCart } from '../../hooks/useCart';
// After: clean alias import, stable regardless of file location
import { Button } from '@/components/ui/Button';
import { formatCurrency } from '@/utils/format';
import { useCart } from '@/hooks/useCart';
2. The "paths" option in tsconfig.json explained
The paths option in the compiler section of tsconfig.json maps import patterns like @/* to one or more physical paths relative to baseUrl. TypeScript uses this mapping for exactly two purposes: type resolution during development and error checking during compilation. The compiler searches the candidate paths in the order given and uses the first match that yields a matching declaration file or source file. The asterisk acts as a wildcard that captures exactly one remaining path segment and gets substituted at the same position in every entry of the target list.
It's important that paths works purely additively on top of normal module resolution and doesn't create new modules, it only makes existing files reachable under an additional, shorter name. Multiple target paths per pattern are allowed, for example to support both an old and a new folder during a migration. This flexibility quickly becomes a trap, though, when developers assume that setting this option alone is enough for the alias to work in the built code as well.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/utils/*": ["src/utils/*"],
"@config": ["src/config/index.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
3. baseUrl and how it interacts with paths
baseUrl defines the root directory from which all non-relative imports and all target paths defined in paths are resolved. Without a set baseUrl, TypeScript handles paths entries inconsistently depending on the version. A common value is "baseUrl": "." combined with "paths": { "@/*": ["src/*"] }, which makes everything under src/ reachable through the short alias, without repeating the folder name src in every import path.
Since TypeScript 4.1, baseUrl is technically no longer required when using paths, but it's still worth setting explicitly, because many bundlers and editor plugins continue to expect it as a reference point. A common mistake happens when baseUrl is set to src, but paths entries additionally start with src/ again. That produces doubly resolved paths like src/src/components, which TypeScript quietly corrects for editor purposes, but which cause real resolution errors in some bundler configurations.
4. Why TypeScript alone does not rewrite imports at runtime
The central misconception when using path mapping is this: TypeScript is a type checker and transpiler, not a module bundler. The tsc command checks import paths against the paths configuration to resolve types correctly, but when compiling to JavaScript it writes the import path itself into the output file completely unchanged. An import like import { Button } from '@/components/ui/Button' stays exactly from '@/components/ui/Button' in the JavaScript after tsc, because tsc has no logic to replace @/* with the actual relative path.
At runtime, Node.js or the browser tries to find this module through the regular module resolution algorithm, but knows neither the @ prefix nor the mapping from tsconfig.json. The result is a Cannot find module '@/components/ui/Button' error, even though tsc reported zero errors during compilation. This gap between a successful compile and failing runtime behavior surprises many developers using aliases for the first time, because TypeScript itself gives no warning that an additional toolchain step is required for runtime resolution.
5. Matching bundler config: Vite resolve.alias
Vite resolves modules through Rollup and its own dev server, which operates completely independently of the TypeScript configuration unless a plugin bridges the gap. The most direct solution is to declare the same aliases manually in the resolve.alias section of vite.config.ts, using the path module and fileURLToPath to produce an absolute path to the src directory. This configuration must match the paths mapping in tsconfig.json exactly, otherwise type checking works fine in the editor while the Vite build fails with a resolution error.
Alternatively, the vite-tsconfig-paths plugin automates this synchronization by reading tsconfig.json at build time and deriving the aliases automatically from the paths entries. This reduces duplication and prevents the two configurations from drifting apart as the project evolves. For simple projects with a few stable aliases, the manual variant is often sufficient and more transparent, while the plugin pays off in complex monorepos with many packages and frequently changing paths.
// vite.config.ts
import { defineConfig } from 'vite';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
resolve: {
alias: {
// Must match the "@/*" -> "src/*" mapping in tsconfig.json exactly
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@config': fileURLToPath(new URL('./src/config/index.ts', import.meta.url)),
},
},
});
6. Matching bundler config: Webpack resolve.alias / tsconfig-paths
Webpack doesn't know about tsconfig.json on its own either. The native approach is to manually declare the same mappings from paths in the resolve.alias section of webpack.config.js, each as an absolute path via path.resolve. Since loaders like ts-loader or babel-loader merely translate TypeScript into JavaScript without knowing about import paths, the actual alias resolution happens exclusively in Webpack's module resolution phase, completely separate from the TypeScript compilation itself.
The tsconfig-paths-webpack-plugin package handles this synchronization automatically, reading the paths entries from tsconfig.json at build time and feeding them into Webpack's resolver. It's registered in the resolve.plugins section and requires no manually duplicated paths. For projects with multiple tsconfig.json files, for example in monorepos with a tsconfig.base.json and project-specific extensions, the configFile parameter must be set explicitly, otherwise the plugin reads the wrong configuration file and the aliases resolve to nothing.
// webpack.config.ts
import path from 'node:path';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
export default {
resolve: {
extensions: ['.ts', '.tsx', '.js'],
alias: {
// Manual fallback, kept in sync with tsconfig.json "paths"
'@': path.resolve(__dirname, 'src'),
},
plugins: [
new TsconfigPathsPlugin({ configFile: path.resolve(__dirname, 'tsconfig.json') }),
],
},
};
7. Matching the Node.js runtime: tsc-alias, tsconfig-paths/register, or subpath imports
When TypeScript code is compiled directly to JavaScript via tsc and then run with plain node, none of the previous bundler solutions apply, because Node.js has no alias system of its own. The tsc-alias tool runs after tsc as a second step and rewrites all alias imports in the compiled .js files into relative paths, so Node.js understands them without further help. It's typically invoked as tsc && tsc-alias in the build script, keeping compilation and path rewriting as separate, traceable steps.
For development without a preceding compile step, tsconfig-paths/register registers a Node.js loader hook at runtime that reads paths entries live from tsconfig.json and resolves alias imports in the running process, for example via node -r tsconfig-paths/register dist/index.js or combined with ts-node. A third, bundler-independent alternative is native Node.js subpath imports through the imports block in package.json, which use a # prefix and have worked without any extra package since Node 12, though they require their own maintenance separate from tsconfig.json.
# Install tsc-alias alongside TypeScript
npm install --save-dev tsc-alias typescript
# package.json build script: compile, then rewrite alias imports to relative paths
# "build": "tsc && tsc-alias -p tsconfig.json"
npm run build
# Result in dist/index.js:
# before: require("@/utils/format")
# after: require("../utils/format")
node dist/index.js
8. Common pitfalls and debugging "Module not found"
The most common mistake is the gap between type checking and runtime described above: the editor shows no errors, tsc --noEmit passes cleanly, but node dist/index.js fails with Cannot find module because the bundler or Node configuration wasn't updated to match. A second classic case involves test runners like Jest or Vitest, which bring their own module resolution and ignore paths from tsconfig.json by default, unless moduleNameMapper (Jest) or resolve.alias in the Vitest configuration is explicitly set. Tests then fail in isolation even though the application and build work perfectly.
A third pitfall shows up with ESLint import rules like import/no-unresolved, which incorrectly flag the same alias as unresolvable without eslint-import-resolver-typescript. For systematic debugging, it helps to isolate the failure along the toolchain: first tsc --noEmit for pure type checking, then the bundler build separately, then the actual runtime start, and finally the test configuration on its own. Narrowing down the failing step this way almost always reveals a missing or mismatched alias declaration in exactly one tool in the chain.
9. Path mapping approaches compared
The table below compares typical scenarios where path mapping is either configured correctly or breaks at a single missing spot in the toolchain. The difference between the risky and the recommended column usually decides whether an alias works simultaneously across every environment, editor, build, runtime, and tests, or only in a single one of them.
| Scenario | Risky | Recommended | Benefit |
|---|---|---|---|
| Import style | ../../../components/Button | @/components/Button | Stable across file moves |
| tsconfig paths alone | paths without a bundler alias | paths + matching bundler alias | Still works after the build |
| tsc build for Node | tsc without tsc-alias | tsc && tsc-alias | Node finds the compiled modules |
| Test runner | moduleNameMapper forgotten | moduleNameMapper kept in sync | Tests don't fail in isolation |
| Path scope | Absolute paths from project root | Scoped @/* alias | No leaking of build details |
In practice, a single forgotten spot is enough for path mapping to work in one environment and break in another. Running through the table as a checklist for every new environment, editor, bundler, Node runtime, and test runner, avoids the classic "works on my machine" situations that come up especially often with aliases.
Mironsoft
TypeScript tooling, build configuration, and frontend infrastructure
Aliases that actually work in every environment?
We analyze your tsconfig.json, bundler, and test configuration, and set up path mapping so that editor, build, runtime, and tests consistently follow the same aliases, without module-not-found surprises after deployment.
tsconfig audit
Checking paths, baseUrl, and moduleResolution for consistency and error sources
Bundler integration
Setting up Vite, Webpack, and the Node build so aliases resolve everywhere
Test & CI setup
Keeping Jest/Vitest moduleNameMapper and the ESLint resolver in sync with tsconfig
10. Summary
Path mapping and aliases solve a real readability and maintainability problem: @/components/Button instead of ../../../../components/Button makes import paths independent of the physical file location. The paths option in tsconfig.json is the right starting point for this, but it only solves type checking in the editor and during compilation, it does not rewrite import paths when translating to JavaScript. Without a matching addition in the bundler, in Node.js, or in the test runner, the alias remains plain text in the built code and leads to runtime errors that TypeScript itself never flags.
The decisive lever is consistency across the entire toolchain: the same alias definition needs to live in tsconfig.json, in the bundler (Vite resolve.alias or Webpack with tsconfig-paths-webpack-plugin), in the Node runtime (tsc-alias or tsconfig-paths/register), and in the test runner (Jest moduleNameMapper, Vitest resolve.alias). Keeping these four spots in sync, or using a plugin that derives the configuration automatically from tsconfig.json, reliably avoids the classic "Cannot find module" errors after deployment.
Path Mapping and Aliases - The Essentials at a Glance
paths only solves types
tsconfig.json paths drives type resolution and editor autocomplete, but doesn't rewrite JavaScript imports.
Bundler alias required
Vite resolve.alias or Webpack with tsconfig-paths-webpack-plugin must match paths exactly.
Match the Node runtime
tsc-alias for compiled code, tsconfig-paths/register for development without a build step.
Don't forget tests
Maintain Jest moduleNameMapper and Vitest resolve.alias separately or derive them from tsconfig.