TypeScript path aliases, often configured using @ symbols in tsconfig.json, don’t actually do anything in production JavaScript.
Let’s see this in action. Imagine you have a simple project structure:
.
├── src/
│ ├── components/
│ │ └── Button.ts
│ └── utils/
│ └── api.ts
└── tsconfig.json
And your tsconfig.json looks like this:
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"strict": true,
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
},
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}
In your src/utils/api.ts file, you might have:
// src/utils/api.ts
export function fetchData() {
console.log("Fetching data...");
// Imagine actual fetch logic here
return Promise.resolve({ data: "some data" });
}
And in src/components/Button.ts:
// src/components/Button.ts
import { fetchData } from "@utils/api";
export function Button() {
console.log("Button component rendered.");
fetchData().then(data => {
console.log("Received:", data.data);
});
return "<button>Click Me</button>";
}
When you run tsc (the TypeScript compiler), it reads your tsconfig.json. The paths configuration tells the TypeScript compiler how to resolve these @ imports during the transpilation phase. It effectively rewrites your imports to point to the actual file locations.
After running tsc, your dist directory will contain the compiled JavaScript:
dist/
├── components/
│ └── Button.js
└── utils/
└── api.js
The dist/components/Button.js file will look something like this:
// dist/components/Button.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Button = void 0;
// The import has been rewritten by tsc!
var api_1 = require("../utils/api");
function Button() {
console.log("Button component rendered.");
api_1.fetchData().then(function (data) {
console.log("Received:", data.data);
});
return "<button>Click Me</button>";
}
exports.Button = Button;
Notice how @utils/api has been replaced with ../utils/api. This is the magic of paths in tsconfig.json – it’s a compile-time feature. The TypeScript compiler uses it to understand your project structure and generate correct relative or absolute paths in the output JavaScript.
The problem arises when you try to run this JavaScript in a Node.js environment or bundle it for a browser without a further processing step. These JavaScript runtimes don’t understand tsconfig.json or TypeScript path aliases. They just see require("../utils/api") or import ... from "@utils/api".
If you try to run node dist/components/Button.js directly, you’ll likely get an error like:
Error: Cannot find module '@utils/api'
or if the import was left as is (which tsc prevents when paths is configured):
Error: Cannot find module '../utils/api'
This happens because the JavaScript runtime doesn’t know how to map @utils/api to ./src/utils/api. The baseUrl option in tsconfig.json also only affects the TypeScript compiler’s resolution, not the JavaScript runtime’s.
How to fix this for production:
-
Bundlers (Webpack, Rollup, Parcel, esbuild): This is the most common solution. Bundlers integrate with TypeScript and understand
tsconfig.json. When they process your code, they’ll use thepathsconfiguration to resolve the aliases and generate output where all imports are correctly resolved relative to the bundled output. You typically configure your bundler to readtsconfig.jsonfor alias information.- Webpack Example (using
tsconfig-paths-webpack-plugin): In yourwebpack.config.js:
This plugin tells Webpack to use yourconst TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); const path = require('path'); module.exports = { // ... other webpack config resolve: { extensions: ['.ts', '.js'], plugins: [ new TsconfigPathsPlugin({ configFile: path.resolve(__dirname, './tsconfig.json') }) ] }, // ... };tsconfig.json’spathsandbaseUrlto resolve module requests.
- Webpack Example (using
-
ts-nodewith--transpile-onlyandtsconfig-paths(for development/testing): If you’re running TypeScript directly withts-nodefor development or testing, you need to ensurets-nodecan resolve these aliases.- Install:
npm install --save-dev ts-node typescript tsconfig-paths - Run:
ts-node -r tsconfig-paths/register src/index.tsThe-r tsconfig-paths/registerflag loads thetsconfig-pathsmodule, which hooks into Node.js’s module loading mechanism to resolve the path aliases defined in yourtsconfig.jsonat runtime. This bypasses the need for a separate compilation step for direct execution.
- Install:
-
Manual Compilation and Runtime Resolution (less common for modern apps): If you’re not using a bundler and want to run compiled JavaScript directly (e.g., in a specific Node.js setup), you’d need a runtime module resolver. The
tsconfig-pathspackage can also be used here.- Install:
npm install tsconfig-paths - Run: Add
require('tsconfig-paths').register();at the very beginning of your entry point file (e.g.,dist/index.js).
This registers a hook with Node.js’s// dist/index.js (modified) require('tsconfig-paths').register(); // Add this line // ... rest of your compiled code var Button_1 = require("./components/Button"); // ...requireto intercept module requests and resolve them based on yourtsconfig.json’spathsandbaseUrl.
- Install:
The core takeaway is that TypeScript’s paths are a compiler directive. They guide tsc on how to translate your source code into output code with correct relative paths. For the runtime environment (Node.js, browser) to understand these aliases, you need an additional tool that either preprocesses the code (bundlers) or intercepts module loading (runtime resolvers like tsconfig-paths).
If you only use tsc to compile and then try to run the output JavaScript directly without any runtime resolution or bundling, you’ll hit "module not found" errors for any files imported via path aliases.