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:

  1. 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 the paths configuration to resolve the aliases and generate output where all imports are correctly resolved relative to the bundled output. You typically configure your bundler to read tsconfig.json for alias information.

    • Webpack Example (using tsconfig-paths-webpack-plugin): In your webpack.config.js:
      const 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')
            })
          ]
        },
        // ...
      };
      
      This plugin tells Webpack to use your tsconfig.json’s paths and baseUrl to resolve module requests.
  2. ts-node with --transpile-only and tsconfig-paths (for development/testing): If you’re running TypeScript directly with ts-node for development or testing, you need to ensure ts-node can resolve these aliases.

    • Install: npm install --save-dev ts-node typescript tsconfig-paths
    • Run: ts-node -r tsconfig-paths/register src/index.ts The -r tsconfig-paths/register flag loads the tsconfig-paths module, which hooks into Node.js’s module loading mechanism to resolve the path aliases defined in your tsconfig.json at runtime. This bypasses the need for a separate compilation step for direct execution.
  3. 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-paths package 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).
      // dist/index.js (modified)
      require('tsconfig-paths').register(); // Add this line
      // ... rest of your compiled code
      var Button_1 = require("./components/Button");
      // ...
      
      This registers a hook with Node.js’s require to intercept module requests and resolve them based on your tsconfig.json’s paths and baseUrl.

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.

Want structured learning?

Take the full Typescript course →