How to Configure Path Aliases in Frontend Projects in the Native Way
Tags: webdev, javascript, typescript, node
Published: 2023-05-04 18:59:34

We’ll take a look at the `imports` field in `package.json` and how it can be used to configure path aliases. We’ll also explore how commonly used development tools support this field and determine the optimal configuration for various use cases.

## Content
* [About Path Aliases](#about-path-aliases)
* [The Imports Field](#the-imports-field)
* [Configuring Path Aliases](#configuring-path-aliases)
* [Limitations of Node.js](#limitations-of-nodejs)
* [Support for Subpath Imports in TypeScript](#support-for-subpath-imports-in-typescript)
* [Support for Subpath Imports in Code Bundlers](#support-for-subpath-imports-in-code-bundlers)
* [Support for Subpath Imports in Test Runners](#support-for-subpath-imports-in-test-runners)
* [Support for Subpath Imports in Code Editors](#support-for-subpath-imports-in-code-editors)
* [Recommended Configuration](#recommended-configuration)
* [Conclusion](#conclusion)
* [Useful Links](#useful-links)


## About Path Aliases <a name="about-path-aliases"></a>

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. 

```tsx
// 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](https://github.com/davestewart/alias-hq) and [tsconfig-paths](https://github.com/dividab/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.


## The Imports Field <a name="the-imports-field"></a>

Starting from Node.js v12.19.0, developers can use [Subpath Imports](https://nodejs.org/api/packages.html#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](https://github.com/jkrems/proposal-pkg-exports/)". 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 <a name="configuring-path-aliases"></a>

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

```json
{
    "name": "my-awesome-project",
    "imports": {
        "#*": "./src/*"
    }
}
```

To use the configured alias, imports can be written like this:

```tsx
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:

```tsx
{
    "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 <a name="limitations-of-nodejs"></a>

If you plan to use path aliases with [CommonJS modules](https://en.wikipedia.org/wiki/CommonJS), I have bad news for you: the following code will not work.

```tsx
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:

```tsx
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 <a name="support-for-subpath-imports-in-typescript"></a>

To properly resolve imported modules for type checking, TypeScript needs to support the `imports` field. This feature is [supported](https://github.com/microsoft/TypeScript/issues/44848) 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.

```tsx
{
    "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.

```tsx
// 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:

```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"
        ]
    }
}
```

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.

```tsx
// 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](https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#moduleresolution-bundler) 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.

```tsx
{
    "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.

```tsx
// 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.

```tsx
{
    "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.

```tsx
{
    "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.

```bash
node --conditions=production build/index.js
```

## Support for Subpath Imports in Code Bundlers <a name="support-for-subpath-imports-in-code-bundlers"></a>

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.

```bash
{
    "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](https://github.com/webpack/webpack/releases/tag/v5.0.0-beta.31) 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:

```jsx
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](https://github.com/vitejs/vite/pull/7770) 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:

```jsx
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 <a name="support-for-subpath-imports-in-test-runners"></a>

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](https://github.com/jestjs/jest/releases/tag/v29.4.0) 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 <a name="support-for-subpath-imports-in-code-editors"></a>

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](https://github.com/microsoft/TypeScript/issues/52460) 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`.

```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](https://youtrack.jetbrains.com/issue/WEB-51282/Support-imports-field-of-package.json) 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.

```tsx
{
    "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 <a name="recommended-configuration"></a>

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.

```json
// 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.

```jsx
// 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.

```jsx
// 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 <a name="conclusion"></a>

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 <a name="useful-links"></a>

1. [RFC for implementing exports and imports](https://github.com/jkrems/proposal-pkg-exports/)
2. [A set of tests to better understand the capabilities of the imports field](https://github.com/lukeed/resolve.exports/blob/master/test/index.ts)
3. [Documentation on the imports field in Node.js](https://nodejs.org/api/packages.html#imports)
4. [Node.js limitations on import paths in ES modules](https://nodejs.org/api/esm.html#import-specifiers)