Path Aliases Done Right: Making @/ Work in TypeScript, Vite, Jest, and Node

You’re knee-deep in a React or Node project, and somewhere in your codebase there’s an import that looks like this:

import { formatDate } from '../../../../shared/utils/format';

Four levels up. Maybe five. You move a file and the whole chain breaks. You rename a folder and spend 20 minutes playing find-and-replace across 40 files. This is the kind of thing that makes people quit and go write COBOL.

The fix is simple: import { formatDate } from '@/shared/utils/format'. One @/. Works from anywhere in the project. Never changes when you restructure.

The catch? TypeScript alone isn’t enough. You need to teach every tool in your chain — compiler, bundler, test runner, runtime — about your alias. Miss one, and you’ll get a cryptic module-not-found error that wastes an hour of your life.

This article is a complete, tool-by-tool breakdown of how to make @/ resolve correctly in every environment. No half-measures.


Why TypeScript Isn’t Enough

Most guides stop at tsconfig.json. They add the paths config, TypeScript stops complaining, and they ship the article. But TypeScript’s path resolution is purely a type-checking and editor concern. It does not affect runtime behavior.

When you run your code, something else is in charge: Node.js, Vite, Webpack, esbuild, Jest. TypeScript hands off a compiled (or type-stripped) file, and that tool sees the raw @/... string. If it doesn’t know what @/ means, you get a runtime crash.

This is the core confusion. TypeScript’s paths is a hint for the type checker. Everyone else needs to be told separately.


Step 1: TypeScript — The Source of Truth

Start in tsconfig.json. This is the canonical declaration of what @/ means. Everything else should reference or mirror this.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

baseUrl: "." sets the root for resolution (your project root). The paths entry maps @/anything to src/anything.

If you’re using project references or a split tsconfig.app.json / tsconfig.node.json structure (common with Vite), put the alias in whichever tsconfig covers your source files, and make sure tsconfig.json extends or includes it.

Gotcha: baseUrl is required whenever you use paths. Omit it and TypeScript will silently ignore your paths config. No error, no warning — just broken imports that look fine in the editor.


Step 2: Vite

Vite uses Rollup under the hood and has its own resolution pipeline. It does not read tsconfig.json paths at runtime. You must configure the alias explicitly in vite.config.ts.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      // Must match tsconfig.json paths exactly
      '@': path.resolve(__dirname, './src'),
    },
  },
});

The path.resolve(__dirname, './src') gives you an absolute path, which is what Vite needs. Relative paths in alias entries are a common source of bugs — they work locally and break in CI.

Gotcha: The alias key '@' (no trailing slash) works differently from '@/' (with slash). Vite matches prefixes, so '@' will catch @/components and also accidentally catch a package named @scope/something. Be specific if you have scoped npm packages in the mix. In most projects '@' is fine; if you import @radix-ui/... or similar, test this carefully.

Production tip: Install vite-tsconfig-paths if you want Vite to automatically sync with tsconfig.json paths instead of maintaining two configs:

npm install -D vite-tsconfig-paths
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
});

This plugin reads your tsconfig.json and feeds the paths to Vite automatically. One source of truth, no duplication. Use this if you have more than one or two aliases.


Step 3: Webpack

Webpack uses resolve.alias in webpack.config.js. Same principle as Vite, different config shape.

const path = require('path');

module.exports = {
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
    // Tell webpack to try .ts and .tsx extensions
    extensions: ['.ts', '.tsx', '.js', '.jsx'],
  },
};

If you’re using ts-loader or babel-loader with TypeScript, the alias still needs to be in Webpack’s config — not just in tsconfig.json. The TypeScript loader passes types through but Webpack controls module resolution.

Gotcha: If you’re using Create React App (CRA), you can’t touch webpack.config.js without ejecting. Your options are craco, react-app-rewired, or migrating to Vite. CRA is effectively dead at this point — if you’re starting fresh, use Vite.


Step 4: Jest

Jest has its own module resolution system that bypasses both TypeScript and your bundler. It needs moduleNameMapper.

{
  "jest": {
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1"
    },
    "transform": {
      "^.+\\.(ts|tsx)$": ["ts-jest", {
        "tsconfig": "tsconfig.json"
      }]
    }
  }
}

The regex ^@/(.*)$ matches any import starting with @/ and maps it to <rootDir>/src/$1, where $1 is the captured path segment.

<rootDir> is a Jest magic variable that resolves to the directory containing your package.json. Use it instead of __dirname or relative paths — it’s portable and works in monorepos.

Gotcha: Regex order matters in moduleNameMapper. Jest processes mappers top-to-bottom. If you have multiple aliases, put more specific ones before more general ones.

Gotcha: If you use ts-jest, make sure its tsconfig option points to the right file. Some projects have a separate tsconfig.test.json. Mismatch here causes mysterious failures where your aliases work in production but not in tests.


Step 5: Vitest

Vitest reads Vite’s config, so if you’ve already set up Vite aliases in vite.config.ts, Vitest picks them up automatically. This is the main reason to prefer Vitest over Jest in Vite projects.

If you want to be explicit or you’re using a separate vitest.config.ts:

import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    environment: 'jsdom',
  },
});

Or, better, extend your Vite config:

import { defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';

export default mergeConfig(viteConfig, defineConfig({
  test: {
    environment: 'jsdom',
  },
}));

This inherits aliases, plugins, and everything else from your main Vite config. No duplication.


Step 6: Node.js Runtime (ts-node, tsx, plain Node)

Running TypeScript directly with ts-node or tsx requires its own handling. TypeScript compiles with path aliases, but Node still needs to resolve them at runtime.

tsx is a modern TypeScript runner that handles path aliases via tsconfig.json automatically:

npm install -D tsx
npx tsx src/index.ts

If your tsconfig.json has paths set, tsx respects them. No extra config needed in most cases.

Option B: ts-node with tsconfig-paths

ts-node does not handle path aliases by default. You need tsconfig-paths as a loader:

npm install -D ts-node tsconfig-paths
node -r tsconfig-paths/register -r ts-node/register src/index.ts

Or in tsconfig.json:

{
  "ts-node": {
    "require": ["tsconfig-paths/register"]
  }
}

Option C: Compiled output with tsc-alias

If you’re compiling with tsc (not a bundler) and running the compiled JavaScript, the output files still contain @/... imports. tsc strips types but doesn’t rewrite paths.

tsc-alias runs after tsc and rewrites the paths in the compiled output:

npm install -D tsc-alias
// package.json
{
  "scripts": {
    "build": "tsc && tsc-alias",
    "dev": "tsc -w & tsc-alias -w"
  }
}

Gotcha: If you’re using tsc for a Node.js backend without a bundler, this step is often forgotten. The code compiles fine, the server crashes at startup with Cannot find module '@/...'. Every time.


The Monorepo Problem

In a monorepo (Turborepo, Nx, PNPM workspaces), path aliases get complicated. You typically have:

  • A root tsconfig.json with shared config
  • Package-level tsconfig.json that extend the root
  • Each package has its own src/

The mistake is defining @/* in the root tsconfig with a path relative to the root. Every package then resolves @/ to the root src/, not its own.

The fix: Define @/* in each package’s tsconfig.json, not the root. Or use package-specific aliases like @myapp/* and @shared/* with clear ownership.

// packages/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Each package owns its own @/ mapping. The Vite config for that package points to its own src/. Clean separation.


Keeping Everything in Sync

The real production problem is drift. You add a new alias in tsconfig.json, forget to add it to vite.config.ts, and everything works locally because Vite handles it — then CI fails because Jest doesn’t. Or the reverse.

The cleanest solution is a shared constants file that all configs consume:

// aliases.js (plain JS — readable by all tools)
const path = require('path');

const aliases = {
  '@': path.resolve(__dirname, 'src'),
  '@components': path.resolve(__dirname, 'src/components'),
  '@utils': path.resolve(__dirname, 'src/utils'),
};

module.exports = aliases;

Then in vite.config.ts:

import aliases from './aliases';
export default defineConfig({ resolve: { alias: aliases } });

In jest.config.js:

const aliases = require('./aliases');

// Convert absolute paths to Jest's rootDir-relative format
const moduleNameMapper = Object.fromEntries(
  Object.entries(aliases).map(([key, value]) => [
    `^${key}/(.*)$`,
    `${value}/$1`,
  ])
);

module.exports = { moduleNameMapper };

Now aliases.js is the single source of truth. Add one alias there and it propagates everywhere except tsconfig.json (which still needs a manual update — there’s no clean way around the TypeScript config format).


Gotchas Summary

TypeScript paths without baseUrl: Silent failure. Always set baseUrl.

Vite alias key without trailing slash on scoped packages: '@' prefix-matches everything. Fine for most projects, breaks with scoped npm packages.

Jest moduleNameMapper with wrong regex: A common one is forgetting the $ at the end or missing the capture group. Test your regex against real import strings before committing.

tsc output without tsc-alias: Compiled JS still has @/... strings. Node can’t resolve them.

Monorepo root tsconfig aliases: They resolve relative to the root, not the package. Always define aliases at the package level.

Hot module replacement (HMR) issues with Vite: If you change the alias config, restart the dev server. HMR doesn’t pick up config changes.


Quick Reference

Tool Config Key Notes
TypeScript tsconfig.json → paths Type checking only, not runtime
Vite vite.config.ts → resolve.alias Or use vite-tsconfig-paths
Webpack webpack.config.js → resolve.alias Absolute paths only
Jest jest.config.js → moduleNameMapper Regex-to-rootDir mapping
Vitest Inherits from Vite config Use mergeConfig pattern
ts-node Needs tsconfig-paths/register Or switch to tsx
tsc output Needs tsc-alias post-build Often forgotten for Node backends

Once you get this right — and it does take a one-time investment — you never think about it again. Every new file in the project can import from @/ without caring where it sits in the directory tree. Restructuring is painless. New teammates don’t have to learn the folder depth game.

The TypeScript-only setup takes five minutes. The "works everywhere" setup takes maybe an hour if you hit every gotcha. That hour pays off the first time you rename a directory and nothing breaks.

👁 Views: 202,912 · Unique visitors: 57,425