Projects often evolve into complex, nested directory structures. As a result, import paths may become longer and more confusing, which can negatively affect the code's appearance and make it more difficult to understand where imported code originates from.
Using path aliases can solve the problem by allowing the definition of imports that are relative to pre-defined directories. This approach not only resolves issues with understanding import paths, but it also simplifies the process of code movement during refactoring.
// Without Aliases
import { apiClient } from '../../../../shared/api';
import { ProductView } from '../../../../entities/product/components/ProductView';
import { addProductToCart } from '../../../add-to-cart/actions';
// With Aliases
import { apiClient } from '#shared/api';
import { ProductView } from '#entities/product/components/ProductView';
import { addProductToCart } from '#features/add-to-cart/actions';
There are multiple libraries available for configuring path aliases in Node.js, such as alias-hq and tsconfig-paths. However, while looking through the Node.js documentation, I discovered a way to configure path aliases without having to rely on third-party libraries.
Moreover, this approach enables the use of aliases without requiring the build step.
In this article, we will discuss Node.js Subpath Imports and how to configure path aliases using it. We will also explore their support in the frontend ecosystem.
Starting from Node.js v12.19.0, developers can use Subpath Imports to declare path aliases within an npm package. This can be done through the imports
field in the package.json
file. It is not required to publish the package on npm.
Creating a package.json
file in any directory is enough. Hence, this method is also suitable for private projects.
Here's an interesting fact: Node.js introduced support for the imports
field back in 2020 through the RFC called "Bare Module Specifier Resolution in node.js". While this RFC is mainly recognized for the exports
field, which allows the declaration of entry points for npm packages, the exports
and imports
fields address completely different tasks, even though they have similar names and syntax.
Native support for path aliases has the following advantages in theory:
I tried to configure path aliases in my projects and tested those statements in practice.
As an example, let's consider a project with the following directory structure:
my-awesome-project
├── src/
│ ├── entities/
│ │ └── product/
│ │ └── components/
│ │ └── ProductView.js
│ ├── features/
│ │ └── add-to-cart/
│ │ └── actions/
│ │ └── index.js
│ └── shared/
│ └── api/
│ └── index.js
└── package.json
To configure path aliases, you can add a few lines to package.json
as described in the documentation. For instance, if you want to allow imports relative to the src
directory, add the following imports
field to package.json
:
{
"name": "my-awesome-project",
"imports": {
"#*": "./src/*"
}
}
To use the configured alias, imports can be written like this:
import { apiClient } from '#shared/api';
import { ProductView } from '#entities/product/components/ProductView';
import { addProductToCart } from '#features/add-to-cart/actions';
Starting from the setup phase, we face the first limitation: entries in the imports
field must start with the #
symbol. This ensures that they are distinguished from package specifiers like @
.
I believe this limitation is useful because it allows developers to quickly determine when a path alias is used in an import, and where alias configurations can be found.
To add more path aliases for commonly used modules, the imports
field can be modified as follows:
{
"name": "my-awesome-project",
"imports": {
"#modules/*": "./path/to/modules/*",
"#logger": "./src/shared/lib/logger.js",
"#*": "./src/*"
}
}
It would be ideal to conclude the article with the phrase "everything else will work out of the box." However, in reality, if you plan to use the imports
field, you may face some difficulties.
If you plan to use path aliases with CommonJS modules, I have bad news for you: The following code will not work.
const { apiClient } = require('#shared/api');
const { ProductView } = require('#entities/product/components/ProductView');
const { addProductToCart } = require('#features/add-to-cart/actions');
When using path aliases in Node.js, you must follow the module resolution rules from the ESM world. This applies to both ES modules and CommonJS modules and results in two new requirements that must be met:
It is necessary to specify the full path to a file, including the file extension.
It is not allowed to specify a path to a directory and expect to import an index.js
file. Instead, the full path to an index.js
file needs to be specified.
To enable Node.js to correctly resolve modules, the imports should be corrected as follows:
const { apiClient } = require('#shared/api/index.js');
const { ProductView } = require('#entities/product/components/ProductView.js');
const { addProductToCart } = require('#features/add-to-cart/actions/index.js');
These limitations can lead to issues when configuring the imports
field in a project that has many CommonJS modules. However, if you're already using ES modules, then your code meets all the requirements.
Furthermore, if you are building code using a bundler, you can bypass these limitations. We will discuss how to do this below.
To properly resolve imported modules for type checking, TypeScript needs to support the imports
field. This feature is supported starting from version 4.8.1, but only if the Node.js limitations listed above are fulfilled.
To use the imports
field for module resolution, a few options must be configured in the tsconfig.json
file.
{
"compilerOptions": {
/* Specify what module code is generated. */
"module": "esnext",
/* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "nodenext"
}
}
This configuration enables the imports
field to function in the same way as it does in Node.js. This means that if you forget to include a file extension in a module import, TypeScript will generate an error warning you about it.
// OK
import { apiClient } from '#shared/api/index.js';
// Error: Cannot find module '#src/shared/api/index' or its corresponding type declarations.
import { apiClient } from '#shared/api/index';
// Error: Cannot find module '#src/shared/api' or its corresponding type declarations.
import { apiClient } from '#shared/api';
// Error: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './relative.js'?
import { foo } from './relative';
I did not want to rewrite all the imports, as most of my projects use a bundler to build code, and I never add file extensions when importing modules. To work around this limitation, I found a way to configure the project as follows:
{
"name": "my-awesome-project",
"imports": {
"#*": [
"./src/*",
"./src/*.ts",
"./src/*.tsx",
"./src/*.js",
"./src/*.jsx",
"./src/*/index.ts",
"./src/*/index.tsx",
"./src/*/index.js",
"./src/*/index.jsx"
]
}
}
This configuration allows for the usual way of importing modules without needing to specify extensions. This even works when an import path points to a directory.
// OK
import { apiClient } from '#shared/api/index.js';
// OK
import { apiClient } from '#shared/api/index';
// OK
import { apiClient } from '#shared/api';
// Error: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './relative.js'?
import { foo } from './relative';
We have one remaining issue that concerns importing using a relative path. This issue is not related to path aliases. TypeScript throws an error because we have configured module resolution to use the nodenext
mode.
Luckily, a new module resolution mode was added in the recent TypeScript 5.0 release that removes the need to specify the full path inside imports. To enable this mode, a few options must be configured in the tsconfig.json
file.
{
"compilerOptions": {
/* Specify what module code is generated. */
"module": "esnext",
/* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "bundler"
}
}
After completing the setup, imports for relative paths will work as usual.
// OK
import { apiClient } from '#shared/api/index.js';
// OK
import { apiClient } from '#shared/api/index';
// OK
import { apiClient } from '#shared/api';
// OK
import { foo } from './relative';
Now, we can fully utilize path aliases through the imports
field without any additional limitations on how to write import paths.
When building source code using the tsc
compiler, additional configuration may be necessary. One limitation of TypeScript is that a code cannot be built to the CommonJS module format when using the imports
field.
Therefore, the code must be compiled in ESM format and the type
field must be added to package.json
to run compiled code in Node.js.
{
"name": "my-awesome-project",
"type": "module",
"imports": {
"#*": "./src/*"
}
}
If your code is compiled into a separate directory, such as build/
, the module may not be found by Node.js because the path alias would point to the original location, such as src/
. To solve this problem, conditional import paths can be used in the package.json
file.
This allows already-built code to be imported from the build/
directory instead of the src/
directory.
{
"name": "my-awesome-project",
"type": "module",
"imports": {
"#*": {
"default": "./src/*",
"production": "./build/*"
}
}
}
To use a specific import condition, Node.js should be launched with the --conditions
flag.
node --conditions=production build/index.js
Code bundlers typically use their own module resolution implementation, rather than the one built into Node.js. Therefore, it's important for them to implement support for the imports
field.
I have tested path aliases with Webpack, Rollup, and Vite in my projects, and am ready to share my findings.
Here is the path alias configuration I used to test the bundlers. I used the same trick as for TypeScript to avoid having to specify the full path to files inside imports.
{
"name": "my-awesome-project",
"type": "module",
"imports": {
"#*": [
"./src/*",
"./src/*.ts",
"./src/*.tsx",
"./src/*.js",
"./src/*.jsx",
"./src/*/index.ts",
"./src/*/index.tsx",
"./src/*/index.js",
"./src/*/index.jsx"
]
}
}
Webpack supports the imports
field starting from v5.0. Path aliases work without any additional configuration. Here is the Webpack configuration I used to build a test project with TypeScript:
const config = {
mode: 'development',
devtool: false,
entry: './src/index.ts',
module: {
rules: [
{
test: /\.tsx?$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-typescript'],
},
},
},
],
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
};
export default config;
Support for the imports
field was added in Vite version 4.2.0. However, an important bug was fixed in version 4.3.3, so it is recommended to use at least this version. In Vite, path aliases work without the need for additional configuration in both dev
and build
modes.
Therefore, I built a test project with a completely empty configuration.
Although Rollup is used inside Vite, the imports
field does not work out of the box. To enable it, you need to install the @rollup/plugin-node-resolve
plugin version 11.1.0 or higher. Here's an example configuration:
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { babel } from '@rollup/plugin-babel';
export default [
{
input: 'src/index.ts',
output: {
name: 'mylib',
file: 'build.js',
format: 'es',
},
plugins: [
nodeResolve({
extensions: ['.ts', '.tsx', '.js', '.jsx'],
}),
babel({
presets: ['@babel/preset-typescript'],
extensions: ['.ts', '.tsx', '.js', '.jsx'],
}),
],
},
];
Unfortunately, with this configuration, path aliases only work within the limitations of Node.js. This means that you must specify the full file path, including the extension. Specifying an array inside the imports
field will not bypass this limitation, as Rollup only uses the first path in the array.
I believe it is possible to solve this problem using Rollup plugins, but I have not tried doing so because I primarily use Rollup for small libraries. In my case, it was easier to rewrite import paths throughout the project.
Test runners are another group of development tools that heavily depend on the module resolution mechanism. They often use their own implementation of module resolution, similar to code bundlers. As a result, there's a chance that the imports
field may not work as expected.
Fortunately, the tools I have tested work well. I tested path aliases with Jest v29.5.0 and Vite v0.30.1. In both cases, the path aliases worked seamlessly without any additional setup or limitations. Jest has had support for the imports
field since version v29.4.0.
The level of support in Vitest relies solely on the version of Vite, which must be at least v4.2.0.
The imports
field in popular libraries is currently well-supported. However, what about code editors? I tested code navigation, specifically the "Go to Definition" function, in a project that uses path aliases. It turns out that support for this feature in code editors has some issues.
When it comes to VS Code, the version of TypeScript is crucial. The TypeScript Language Server is responsible for analyzing and navigating through JavaScript and TypeScript code.
Depending on your settings, VS Code will use either the built-in version of TypeScript or the one installed in your project.
I tested the imports
field support in VS Code v1.77.3 in combination with TypeScript v5.0.4.
VS Code has the following issues with path aliases:
TypeScript does not use the imports
field until the module resolution setting is set to nodenext
or bundler
. Therefore, to use it in VS Code, you need to specify the module resolution in your project.
IntelliSense does not currently support suggesting import paths using the imports
field. There is an open issue for this problem.
To bypass both issues, you can replicate a path alias configuration in the tsconfig.json
file. If you are not using TypeScript, you can do the same in jsconfig.json
.
// tsconfig.json OR jsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"#*": ["./src/*"]
}
}
}
// package.json
{
"name": "my-awesome-project",
"imports": {
"#*": "./src/*"
}
}
Since version 2021.3 (I tested in 2022.3.4), WebStorm supports the imports
field. This feature works independently of the TypeScript version, as WebStorm uses its own code analyzer. However, WebStorm has a separate set of issues regarding supporting path aliases:
The editor strictly follows the restrictions imposed by Node.js on the use of path aliases. Code navigation will not work if the file extension is not explicitly specified. The same applies to importing directories with an index.js
file.
WebStorm has a bug that prevents the use of an array of paths within the imports
field. In this case, code navigation stops working completely.
{
"name": "my-awesome-project",
// OK
"imports": {
"#*": "./src/*"
},
// This breaks code navigation
"imports": {
"#*": ["./src/*", "./src/*.ts", "./src/*.tsx"]
}
}
Luckily, we can use the same trick that solves all the problems in VS Code. Specifically, we can replicate a path alias configuration in the tsconfig.json
or jsconfig.json
file. This allows the use of path aliases without any limitations.
Based on my experiments and experience using the imports
field in various projects, I've identified the best path alias configurations for different types of projects.
This configuration is intended for projects where source code runs in Node.js without requiring additional build steps. To use it, follow these steps:
Configure the imports
field in a package.json
file. A very basic configuration is sufficient in this case.
In order for code navigation to work in code editors, it is necessary to configure path aliases in a jsconfig.json
file.
// jsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"#*": ["./src/*"]
}
}
}
// package.json
{
"name": "my-awesome-project",
"imports": {
"#*": "./src/*"
}
}
This configuration should be used for projects where the source code is written in TypeScript and built using the tsc
compiler. It is important to configure the following in this configuration:
The imports
field in a package.json
file. In this case, it is necessary to add conditional path aliases to ensure that Node.js correctly resolves compiled code.
Enabling the ESM package format in a package.json
file is necessary because TypeScript can only compile code in ESM format when using the imports
field.
In a tsconfig.json
file, set the ESM module format and moduleResolution
. This will allow TypeScript to suggest forgotten file extensions in imports. If a file extension is not specified, the code will not run in Node.js after compilation.
To fix code navigation in code editors, path aliases must be repeated in a tsconfig.json
file.
// tsconfig.json
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "nodenext",
"baseUrl": "./",
"paths": {
"#*": ["./src/*"]
},
"outDir": "./build"
}
}
// package.json
{
"name": "my-awesome-project",
"type": "module",
"imports": {
"#*": {
"default": "./src/*",
"production": "./build/*"
}
}
}
This configuration is intended for projects where source code is bundled. TypeScript is not required in this case. If it is not present, all settings can be set in a jsconfig.json
file.
The main feature of this configuration is that it allows you to bypass Node.js limitations regarding specifying file extensions in imports.
It is important to configure the following:
Configure the imports
field in a package.json
file. In this case, you need to add an array of paths to each alias. This will allow a bundler to find the imported module without requiring the file extension to be specified.
To fix code navigation in code editors, you need to repeat path aliases in a tsconfig.json
or jsconfig.json
file.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"#*": ["./src/*"]
}
}
}
// package.json
{
"name": "my-awesome-project",
"imports": {
"#*": [
"./src/*",
"./src/*.ts",
"./src/*.tsx",
"./src/*.js",
"./src/*.jsx",
"./src/*/index.ts",
"./src/*/index.tsx",
"./src/*/index.js",
"./src/*/index.jsx"
]
}
}
Configuring path aliases through the imports
field has both pros and cons compared to configuring it through third-party libraries. Although this approach is supported by common development tools (as of April 2023), it also has limitations.
This method offers the following benefits:
package.json
file).
There are, however, temporary disadvantages that will be eliminated as development tools evolve:
imports
field. To avoid these issues, you can use the jsconfig.json
file. However, this leads to duplication of path alias configuration in two files.
imports
field out of the box. For example, Rollup requires the installation of additional plugins.
imports
field in Node.js adds new constraints on import paths. These constraints are the same as those for ES modules, but they can make it more difficult to start using the imports
field.
So, is it worth using the imports
field to configure path aliases? I believe that for new projects, yes, this method is worth using instead of third-party libraries.
The imports
field has a good chance of becoming a standard way to configure path aliases for many developers in the coming years, as it offers significant advantages compared to traditional configuration methods.
However, if you already have a project with configured path aliases, switching to the imports
field will not bring significant benefits.
I hope you have learned something new from this article. Thank you for reading!
Also published here