TypeScript config and tooling · 5 / 8
lesson 5

Path aliases

@/components is one config away. The catch is your bundler also has to know.

~ 12 min read·lesson 5 of 8
0 / 8

You've seen this in someone else's project: import { Button } from '@/components/Button'. No ../../../. The @/ is doing some kind of magic. It looks like a package, but there's no @ package installed. It's an alias — a shortcut from a short path to a real folder. Setting one up is a five-line change, but the catch is that it breaks if only TypeScript knows about it. The bundler has to know too.

This lesson sets up an alias correctly and walks through what goes wrong when the wiring is partial.

Setting up paths and baseUrl

Two compilerOptions set up an alias: baseUrl and paths.

baseUrl is the folder paths in paths are resolved from. For most projects it is the project root or the src/ folder.

paths is a map from alias patterns to one or more real paths. Each alias entry uses * as a wildcard.

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

Read it as: "Anything starting with @/, find it under src/." So @/components/Button becomes src/components/Button for the type-checker.

Once that is in place, import { Button } from '@/components/Button' resolves to the right file. The editor types it correctly. Hover and Go-to-Definition work. So far so good — but only inside TypeScript's view of the project.

src/pages/Cart.tsx
import { Button } from '@/components/Button';
import { format } from '@/lib/format';

/* both resolve cleanly to src/components/Button and src/lib/format */

You can have multiple aliases. A common pattern is @/components/*, @/lib/*, @/hooks/* — each pointing into its own subfolder. The wildcard rule is: whatever matches * in the key replaces * in the value.

check your understanding
Your tsconfig has "@lib/*": ["src/utilities/*"]. You write import x from "@lib/format". Which file does the compiler look for?

Two tools have to agree

Here is the trap. paths only teaches TypeScript about the alias. TypeScript is the type-checker — it does not produce the JavaScript that ships. The bundler does. And the bundler has its own resolver, which knows nothing about tsconfig.json's paths field by default.

If you set up paths and stop there, you get this:

  • The editor is happy. Imports resolve. Auto-complete works. tsc --noEmit passes.
  • The bundler runs. It sees import '@/components/Button'. It tries to resolve @/components/Button as a package. There is no package called @. It crashes.
terminal output
[vite] Internal server error: Failed to resolve import "@/components/Button"
from "src/pages/Cart.tsx". Does the file exist?

The fix is to teach the bundler the same mapping. Each bundler has its own way to do this. The shape is always: "When you see this prefix, look in this folder."

Watch out

An alias that works in the editor but breaks in the build is the most common bundler-config bug. The editor is reading tsconfig.json; the bundler isn't.

Wiring it into Vite

Vite expects the alias in vite.config.ts. It uses an absolute filesystem path, not a glob.

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

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

That entry says: "Whenever you see an import that starts with @, treat the rest of the path as relative to the absolute path of src/." Now the bundler resolves @/components/Button to <project>/src/components/Button — exactly the same place TypeScript was looking.

Webpack uses resolve.alias with a similar shape. Next.js reads tsconfig.json directly and supports paths natively — one of the few bundlers that does. Jest and Vitest each have their own alias config. Every tool that resolves modules needs the mapping.

tscvitevitest@/ → src/
The same alias has to be configured in every tool that resolves modules.

The figure is the rule: every tool in the resolution path needs the alias. Skip one and you get a tool-shaped hole — green editor, broken tests, or green tests, broken build.

check your understanding
Your editor and tsc --noEmit both pass. The Vite dev server crashes on @/components/Button. What is the fix?

When aliases hurt

Aliases are not free. They have failure modes worth knowing before you decide to use them.

Refactoring tools get worse. Renaming src/components/ to src/ui/ should mechanically rewrite every import. With aliases, half your imports say @/components/... and the editor's rename-folder rewriter may or may not understand that. Some editors handle it, some skip it. Test before you trust it.

Deep imports become opaque. import { x } from '@/lib/auth/internal/cache/keys' looks fine in the file. It hides that you reached deep into another module's internals. Relative paths (../../auth/internal/cache/keys) are uglier, but the ugliness is a feature — it makes deep coupling visible.

Aliases inside a published package break. If you ship a library to npm with @/utils in its source, the published code still has @/utils in it. Consumers' bundlers will not know your alias. Libraries should compile aliases away (some bundlers do this, some do not) or avoid them altogether.

For an app project, one alias from @/ to src/ is a clean win. For a library or a project with three nested aliases mapping to overlapping folders, the overhead starts to outweigh the convenience.

Tip

One alias is fine. Three aliases overlapping (@/components/*, @components/*, @/ui/*) is technical debt. Pick one shape and stick to it.

check your understanding
You're publishing a library to npm. Your source uses @/utils. Your bundler emits the same @/utils string into the published JS. What happens when a consumer installs your library?
check your understanding
You add "@/*": ["src/*"] to paths but forget to set baseUrl. What happens with modern TypeScript versions?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript config and tooling
0 of 8 read