ESC

Type to search the knowledge base.

Path Aliases and Tooling Sync

Keep tsconfig paths, bundler aliases, Vitest, and ESLint in sync so @/ imports resolve everywhere — not just in the IDE.

intermediate3 min read
  • typescript
  • path-aliases

Path aliases (@/components/Button) keep imports stable as files move. The trap: TypeScript understands paths in tsconfig, but the bundler, test runner, and ESLint each need their own mapping (or a shared plugin). When they drift, you get “works in VS Code, fails in CI.”

Docs: Module Resolution, tsconfig paths.

tsconfig base

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}
import { Button } from '@/components/Button';

baseUrl + paths are for the typechecker / editor. They do not rewrite emit by default (tsc still won’t rewrite paths unless you add a tool).

Bundler must agree

Vite

// vite.config.ts
import path from 'node:path';
import { defineConfig } from 'vite';

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

Webpack

resolve: {
  alias: { '@': path.resolve(__dirname, 'src') },
}

Next.js

Often reads jsconfig/tsconfig paths automatically — still verify version behavior.

Tests (Vitest / Jest)

// vitest.config.ts
export default defineConfig({
  resolve: {
    alias: { '@': path.resolve(__dirname, 'src') },
  },
  // or use vite-tsconfig-paths plugin
});
// jest
moduleNameMapper: {
  '^@/(.*)$': '<rootDir>/src/$1',
}

Plugin approach: vite-tsconfig-paths / tsconfig-paths so one source of truth drives multiple tools.

ESLint import plugin

// eslint.config.js snippets
settings: {
  'import/resolver': {
    typescript: { project: './tsconfig.json' },
  },
}

Without a TypeScript resolver, import/no-unresolved false-positives on @/.

Storybook / Playwright / MSW

Each tooling process that loads your source needs the alias:

  • Storybook Vite builder — share Vite config
  • Playwright component testing — same as Vite/Webpack
  • Node scripts using tsx — register tsconfig-paths/register if required

Relative vs alias policy

Import Prefer
Deep ../../../ across features Alias @/
Sibling in same folder Relative ./
Public package boundary Package name

Don’t alias everything — local relatives still read well for colocated files.

Monorepos

"paths": {
  "@app/*": ["apps/web/src/*"],
  "@ui/*": ["packages/ui/src/*"]
}

Prefer workspace package names ("@org/ui") with real package.json exports over infinite path mapping. Paths are a convenience; packages are a boundary.

Common failure modes

  1. IDE ok, vite build fails — missing bundler alias.
  2. Tests fail only in CI — Jest mapper missing.
  3. tsc --noEmit ok, runtime wrong — you’re typechecking paths that never rewrite for a raw Node run.
  4. Duplicate aliases — @ vs @/ inconsistency.
// pick one style and enforce
import x from '@/lib/x';
// not sometimes '@lib/x' and sometimes 'src/lib/x'

Checklist for a new alias

  1. Add paths in the relevant tsconfig.
  2. Mirror in bundler.
  3. Mirror in test runner (or use tsconfig-paths plugin).
  4. Teach ESLint resolver.
  5. Grep CI scripts for other entrypoints (codegen, e2e).
  6. Document in README.

Interview out-loud answer

“paths only teaches TypeScript. Runtime tools need matching aliases or a shared tsconfig-paths integration. I keep one mapping, sync Vite/Vitest/ESLint, and prefer package boundaries in monorepos over deep path hacks.”

Further reading

Related guides