Path aliasing, sometimes referred to as import or module resolution, in the earliest and most naïve sense, is a method of overloading the Node Require Resolution Algorithm so that it first looks in some particular defined folders for modules of a given name before looking for installed modules of the same name in node_modules
folders. In the early days of Node.js first availability, this was somewhat common practice to overload the require.paths
array with some extra stuff:
require.paths.unshift(path.join(__dirname, 'src'));
With modern tooling, like TypeScript compiler paths, Vite resolve.alias
, WebPack resolve.alias
, @rollup/plugin-alias
, esbuild-plugin-alias, and many others, more specific aliasing or even prefixes for entire paths can be used:
{ "compilerOptions": { "paths": { "@/*": "./src/*", } }}
When configured using the above, any TypeScript project can have files that import from the prefixed alias, short-circuiting the relative lookups that are normally done.
import { Button } from '@/components/Button';
The problems with import path aliasing
Import path aliasing is a crutch for poorly organized and architected codebases.
The following example probably looks fine to most people:
import Button from '@/components/Button';import TextInput from '@/components/TextInput';import useGetUserQuery from '@/shared/api/get-user';import useLoginMutation from '@/shared/api/login';
Expanded out to use the relative paths, it may be more clear why people tend to read for prefix aliasing. It’s messy to read repetitive ../
and determine if they are correct or not.
import Button from '../../../../../../../components/Button';import TextInput from '../../../../../../../components/TextInput';import useGetUserQuery from '../../../../../../../shared/api/get-user';import useLoginMutation from '../../../../../../../shared/api/login';
But this code smells. This seven-level deep import in the previous example means that the source file is nested at least seven directories within the root of the project. When code is actively and commonly importing from many levels higher in its file tree and reaching deep into separate folders, the organization of the application as a whole is going to be really difficult to follow and maintain – especially for new team members or infrequent contributors.
Hiding the organizational issues behind path aliasing does nothing for the greater organization, but puts it off for another day.

Lack of standards
The most major issue is that there are no standards across frameworks and tooling. Every setup is ad-hoc. While some projects tend to use something like ~/
or @/
to point to the src/
directory, many do not. Some even alias unprefixed straight to one or more directories.
From project to project, there’s no way to know what the correct aliasing is without referencing the configuration(s). And furthermore, just because a project has aliasing, doesn’t mean that it is enforced. Imports can always written as relative imports.
Enforcement
There’s no way to enforce and ensure that aliases are always used. Files end up with a mix of aliases and relative imports and it is unclear whether the filepath imports are located next to each other or not.
import { Button } from '@/components/form/Button';import { TextField } from '../../../components/form/TextField';
Confusion
The following two examples are canonical, but one is longer and more complicated than the other – and it’s not the alias version.
import { Label } from '@/components/Label';
import { Label } from './Label';
Maintenance issues
Then there are other static analyzers that don’t understand how to read a tsconfig
, vite.config
, or whatever is in use to set up aliases. There are many potential places and ways to configure aliasing – and not all of them are compatible with reusing a single configuration, but will have to be done as duplicated configurations slightly differently.
Consider ESLint and using eslint-plugin-import
rule import/no-cycle
or import/no-self-import
: neither are directly configurable to understand aliasing. Instead, another plugin for ESLint is now required with an array-map configuration, completely different from tsconfig
and other setups: eslint-import-resolver-alias.
module.exports = { settings: { 'import/resolver': { alias: { map: [ ['@', './src'], ], }, }, },};
Down the rabbit hole
There are countless other tools and static analyzers that might need to be configure to use the aliasing as well.
More times than I can remember I have also needed to write large code transforms that read through ImportDeclaration
s, looking for particular files and updating references, imported methods, and then rewriting code based on them. When shared across different applications with different setups, I’ve needed to figure out how to take in custom configurations in order to do the mapping from alias to resolved path.
Published modules
If a reusable module is using path aliasing and is going to be published to a registry, it needs ensure that its build tools also rewrite the aliasing before publish. Not all of the plugins and tools enabling aliasing do this automatically, so it’s yet another step to create with caution and care.
Pandora’s box
A team might say that they’re going to standardize on just using the @/* → ./src/*
aliasing across all of codebases and teams in order to reduce maintenance issues.
But by adding aliasing in the first place, the door is already open for endless possible aliases. Why can’t someone add another alias for a set of common utils that they keep needing? What’s actually stopping them from doing it? If it’s okay, how is it communicated to the team? How is it enforced?
Better organization
Often times, the reason codebases develop problems that make path aliasing attractive comes from the many frameworks with short-sighted recommendations (and sometimes requirements) for how to organize code in an application. Typically for web application, api
, pages
, components
, hooks
, and modules
are seen as top-level and everything is thrown into them.
└── src ├── api ├── components ├── hooks ├── modules └── pages ├── auth │ ├── login.tsx │ └── logout.tsx ├── dashboard └── home
This looks just fine when getting started, but it fails to think forward to the application growing into a feature-rich and complex application. Frustrations start to arise when directories have too many files at a single level, so people come through and try to organize similar things together.
└── src └── components ├── auth │ └── login │ └── Form.tsx ├── cards │ └── … ├── form │ ├── inputs │ │ ├── PasswordInput.tsx │ │ ├── NumberInput.tsx │ │ └── TextInput.tsx │ └── buttons │ ├── PrimaryButton.tsx │ └── SecondaryButton.tsx └── grids └── …
And this may work in the short term. But as teams grow and communication is less clear, these structures become cluttered with internal acronyms, duplication, and much more complex nesting.
Typically, teams become focused as part of a “product pillar”. These pillars allow optimization and specialization in varying areas, which makes colocation all of the application’s code more fragmented, despite best intentions to reuse as much as possible.
Use a monorepo
├── app # infra-team│ └── src│ └── pages│ ├── auth # auth-team│ │ ├── login.tsx│ │ ├── logout.tsx│ │ └── components│ ├── dashboard # home-team│ │ ├── dashboard.tsx│ │ └── components│ └── home # home-team│ ├── home.tsx│ └── components└── modules ├── components # ui-team │ ├── package.json │ └── src ├── api # api-team, infra-team │ ├── package.json │ └── src └── hooks # infra-team ├── package.json └── src
* infra-teamapp/src/pages/home home-teamapp/src/pages/dashboard dashboard-team-teamapp/src/pages/auth auth-teammodules/components ui-teammodules/api api-team, infra-teammodules/hooks infra-team
Bonus mentions
In no particular order and less important, but worth calling out, here are some other pitfalls to avoid:
-
Names of modules and directories that are too generic.
utils
tends to become a dumping ground for everything. Take an extra minute to plan where something should be based on it usage, reusability, and purpose. -
Reduce the number of imports in components.
Importing from 20 or 30 files for a single component means the component is doing too much. Splitting it into logical bits will make it both easier to maintaing and test. Not only that, but patterns may arise that suggest that portions would be better organized somewhere else within the codebase.
Easily revert path aliasing
Hopefully I’ve done a good job and convinced some readers to stop using path aliasing. Unfortunately, the reversion process to move back from aliases to relative imports is manual, difficult, and error-prone.
However, I’ve had to help teams out enough times over the years to finally realize I should make my codemod public and easy to use.
Introducing remove-aliasing
npx remove-aliasing@latest --root="src/" --prefix="@/" src/

Bonus VSCode settings
Updated 2024-03-06.
VSCode comes out of the box with an inconsistent setting: when adding imports for you, it will choose the shortest import path. So if your relative import is even 1-character longer than it would be as an alias, the alias will be used.
You should turn this off.
{ "javascript.preferences.importModuleSpecifier": "project-relative", "typescript.preferences.importModuleSpecifier": "project-relative"}
As another helper, make sure that VSCode automatically updates import paths when you move files. This will save you some confusion in those rare instances where things need to be moved around. Be warned, though: moving files across Workspaces in a monorepo may have unexpected results.
{ "javascript.updateImportsOnFileMove.enabled": "always", "javascript.preferences.importModuleSpecifier": "project-relative", "typescript.updateImportsOnFileMove.enabled": "always", "typescript.preferences.importModuleSpecifier": "project-relative"}