The Native Way To Configure Path Aliases in Frontend Projects
We’ll take a look at the imports field in package.json and how it can be used to configure path aliases for various use cases.
Join the DZone community and get the full member experience.
Join For FreeAbout Path Aliases
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 also simplifies the process of code movement during refactoring.
// Without Path Aliases
import { apiClient } from '../../../../shared/api';
import { ProductView } from '../../../../entities/product/components/ProductView';
import { addProductToCart } from '../../../add-to-cart/actions';
// With Path 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 front-end ecosystem.
The Imports Field
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:
- There is no need to install any third-party libraries.
- There is no need to pre-build or process imports on the fly in order to run the code.
- Aliases are supported by any Node.js-based tools that use the standard import resolution mechanism.
- Code navigation and auto-completion should work in code editors without requiring any extra setup.
I tried to configure path aliases in my projects and tested those statements in practice.
Configuring Path Aliases
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.
Limitations of Node.js
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 anindex.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.
Support for Subpath Imports in TypeScript
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.
Building Code With TypeScript
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
Support for Subpath Imports in Code Bundlers
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
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;
Vite
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.
Rollup
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.
Support for Subpath Imports in Test Runners
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.
Support for Subpath Imports in Code Editors
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.
VS Code
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
orbundler
. 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/*"
}
}
WebStorm
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. Specificaly, 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.
Recommended Configuration
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.
Without TypeScript or a Bundler
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/*"
}
}
Building Code Using TypeScript
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 andmoduleResolution
. 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/*"
}
}
}
Building Code Using a Bundler
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
orjsconfig.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"
]
}
}
Conclusion
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:
- Ability to use path aliases without the need to compile or transpile code “on the fly”.
- Most popular development tools support path aliases without any additional configuration. This has been confirmed in Webpack, Vite, Jest, and Vitest.
- This approach promotes configuring path aliases in one predictable location (
package.json
file). - Configuring path aliases does not require the installation of third-party libraries.
There are, however, temporary disadvantages that will be eliminated as development tools evolve:
- Even popular code editors have issues with supporting the 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. - Some development tools may not work with the imports field out of the box. For example, Rollup requires the installation of additional plugins.
- Using the 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.
- Node.js constraints can result in differences in implementation between Node.js and other development tools. For instance, code bundlers can ignore Node.js constraints. These differences can sometimes complicate configuration, especially when setting up TypeScript.
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!
Useful Links
Published at DZone with permission of Maksim Zemskov. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments