TypeScript path aliases eliminate the pain of deep relative imports like ../../../utils/helpers, replacing them with clean, intention-revealing paths such as @utils/helpers. This article covers configuration, bundler integration, testing, and migration strategies.
Configuring baseUrl and paths
The foundation of path aliases is the paths mapping in tsconfig.json, combined with baseUrl:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@utils/*": ["./src/utils/*"]
}
}
}
baseUrl sets the base directory for resolving non-relative module names. paths maps alias prefixes to file system locations relative to baseUrl. The wildcard * matches any path segment after the prefix.
Wildcard Patterns and Advanced Mappings
Wildcards support flexible mapping strategies:
{
"paths": {
"@shared/*": ["./src/shared/*"],
"@components/*": ["./src/components/*", "./src/shared/components/*"],
"@config": ["./src/config/index.ts"]
}
}
Multiple fallback paths are searched in order if the first location does not exist. Direct module aliases (without wildcards) are useful for specific entry points.
Bundler Integration
TypeScript’s paths only affects type-checking and IDE support. Every bundler needs its own alias configuration:
| Tool | Configuration |
|---|---|
| webpack | resolve.alias: { '@': path.resolve(__dirname, 'src') } |
| Vite | resolve.alias: { '@': '/src' } (or vite-tsconfig-paths plugin) |
| esbuild | --alias:@=./src |
| SWC | jsc.paths mirroring tsconfig |
For Vite, the vite-tsconfig-paths plugin reads your tsconfig automatically, eliminating duplication:
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
});
For Node.js runtime resolution, use the tsconfig-paths package with tsconfig-paths/register.
ESLint Resolution
ESLint’s import/no-unresolved rule will fail on aliased imports without proper resolver configuration. The eslint-import-resolver-typescript package reads tsconfig paths automatically:
settings: {
'import/resolver': {
typescript: { alwaysTryTypes: true }
}
}
You can also configure import/order to group aliased imports separately:
'import/order': ['warn', {
groups: ['builtin', 'external', 'internal', 'parent', 'sibling'],
pathGroups: [
{ pattern: '@/**', group: 'internal', position: 'before' }
]
}]
Monorepo Strategies
In monorepos, each package typically has its own tsconfig.json with path aliases:
- Nx: Project-level tsconfig with root tsconfig for shared paths
- Turborepo: Each package defines its own paths; workspace protocol for cross-package imports
- pnpm workspaces: Prefer workspace protocol over path aliases for cross-package references
TypeScript project references (composite + references) offer an alternative to path aliases in monorepos, providing incremental compilation and faster builds.
Testing with Path Aliases
Test runners also need alias configuration:
// Jest - moduleNameMapper
{ "^@/(.*)$": "<rootDir>/src/$1" }
// Vitest - automatically uses Vite's resolve.alias
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
});
Migration Strategy
Adopt path aliases incrementally. Start with a single @/ alias for new code, then refactor existing imports using codemods like ts-migrate or custom jscodeshift transforms. After migration, enforce conventions with import/no-relative-parent-imports in ESLint.
Avoid common pitfalls: circular dependencies become harder to spot with aliases, and over-alias (creating aliases for every directory) adds cognitive load without benefit.
Path aliases significantly improve code maintainability in mid-to-large TypeScript projects. The key challenge is keeping bundler, test runner, and linter configurations synchronized. Using plugins that auto-read tsconfig reduces this burden substantially.
