The Native Way To Configure Path Aliases

Donations Make us online

About 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:

  1. It is necessary to specify the full path to a file, including the file extension.
  2. 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.

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:

  1. 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.
  2. 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:

  1. 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.
  2. 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:

  1. Configure the imports field in a package.json file. A very basic configuration is sufficient in this case.
  2. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  1. 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.
  2. 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"
        ]
    }
}

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

  1. RFC for implementing exports and imports
  2. A set of tests to better understand the capabilities of the imports field
  3. Documentation on the imports field in Node.js
  4. Node.js limitations on import paths in ES modules

Source link