DEV Community

Cover image for Setting up Subpath Import Aliases in a TypeScript Project
Vitaliy Potapov
Vitaliy Potapov

Posted on • Edited on

Setting up Subpath Import Aliases in a TypeScript Project

TL;DR use custom conditions

One day, I decided to incorporate modern path aliases in my TypeScript project. I didn't expect it would be such a challenging journey! You are welcome to read the details

Content

Intro

Subpath imports are a native feature in Node.js that allows to define aliases for internal paths in a codebase.

For example, instead of writing:

import { foo } from '../../../utils.js';
Enter fullscreen mode Exit fullscreen mode

You can set up a subpath import to simplify this to:

import { foo } from '#utils.js';
Enter fullscreen mode Exit fullscreen mode

Two main benefits:

  1. easier to read
  2. no extra diff after files move

In TypeScript, there is an older way for setting up aliases via paths option. Although it works fine for TypeScript itself, the problem is that Node.js is not aware of this config. You need to use third party packages to run compiled code (tsconfig-paths, tsc-alias).

Great news is that since v5.4 TypeScript added support for subpath imports. While the concept sounds straightforward, integrating subpath imports in my test project was tricky. I'll walk through all the issues step by step and share the final solution. Let's start.

Example Project

Imagine the following project structure:

my-project
├── src
│   ├── index.ts
│   └── utils.ts
├── test
│   └── index.spec.ts
├── package.json
├── tsconfig.build.json
├── tsconfig.json
└── vitest.config.mts
Enter fullscreen mode Exit fullscreen mode

This is a fairly typical setup:

  • src and test contain the source code and unit tests respectively
  • tsconfig.json for type-checking the entire project
  • tsconfig.build.json for compiling the source code from src into the dist directory
  • vitest.config.mts for running unit tests with vitest

Initially, this project uses classic relative paths. src/index.ts imports constant foo from utils:

// src/index.ts
import { foo } from './utils.js';

console.log(foo);

// src/utils.ts
export const foo = 42;
Enter fullscreen mode Exit fullscreen mode

In the test directory there is also import of utils by relative path:

// test/index.spec.ts
import { foo } from '../src/utils.js';

test('foo is 42', () => {
  expect(foo).toBe(42);
});
Enter fullscreen mode Exit fullscreen mode

There are a few npm scripts in package.json, that successfully run in the initial project state:

  "scripts": {
    "tsc": "tsc",
    "test": "vitest run",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/index.js"
  }
Enter fullscreen mode Exit fullscreen mode
  1. npm run tsc - type-check the entire project
  2. npm run test - run tests against the actual code in src
  3. npm run build - compile the code from src into the dist directory
  4. npm start - run the project from the dist directory

I will use these commands as a checklist to ensure everything works after setting up subpath imports.

Also, I will check in VSCode that CMD / CTRL + click on utils import navigates to the file contents.

Attempt #1: Follow Node.js Docs

My first step was to follow the samples from Node.js documentation. I've added an imports field to the package.json and set alias for the src directory.

// package.json
{
  "name": "subpath-imports-typescript",
+  "imports": {
+    "#*": "./src/*"
+  },
}
Enter fullscreen mode Exit fullscreen mode

and used that alias in src/index.ts and test/index.spec.ts:

// src/index.ts
- import { foo } from './utils';
+ import { foo } from '#utils.js';

// test/index.spec.ts
- import { foo } from '../src/utils.js';
+ import { foo } from '#utils.js';
Enter fullscreen mode Exit fullscreen mode

After making these changes, all commands worked except the npm start, which failed with the following error:

> node dist/index.js

node:internal/modules/cjs/loader:1110
        throw e;
        ^

Error: Cannot find module '/projects/subpath-imports-typescript/src/utils.js'
Enter fullscreen mode Exit fullscreen mode

The problem occurred because Node.js was looking for the utils.js in the src directory instead of the dist directory. Since the project is meant to run from the dist directory, Node.js couldn’t find the required file.

Attempt #2: Use dist instead of src

To fix this, I adjusted the imports field in package.json to point to the dist directory instead of src:

{
  "name": "subpath-imports-typescript",
  "imports": {
-    "#*": "./src/*"
+    "#*": "./dist/*"
  },
}
Enter fullscreen mode Exit fullscreen mode

Initially, this change seemed to work. However, once I deleted the dist directory, everything got broken! VSCode could no longer find the #utils.js module, and TypeScript showed an error:

Cannot find module '#utils.js' or its corresponding type declarations.
Enter fullscreen mode Exit fullscreen mode

The root cause was that TypeScript and VSCode couldn’t resolve the alias because the files in the dist directory are missing.

A chicken-and-egg problem - source files refer compiled files to get compiled files 🤪

To address that issue TypeScript documentation recommends setting the rootDir and outDir options.

Attempt #3: Set rootDir and outDir

I've added the rootDir and outDir options to tsconfig.json:

// tsconfig.json
{
  "compilerOptions": {
    "target": "es2021",
    "module": "NodeNext",
+    "rootDir": "src",
+    "outDir": "dist",
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode

The idea here is following: when TypeScript knows the source and destination directories of compiled files, it can re-map import aliases to the source location.

In my case, #utils.js will be first resolved to ./dist/utils.js and then re-mapped to ./src/utils.ts.

However, when I ran tsc, TypeScript threw the following error:

File '/xxx/subpath-imports-typescript/test/index.spec.ts' is not under 'rootDir' 
'/xxx/subpath-imports-typescript/src'. 'rootDir' is expected to contain all source files.
Enter fullscreen mode Exit fullscreen mode

To resolve this, I had to narrow the include option to src/**/*.ts:

{
  "compilerOptions": {
    "target": "es2021",
    "module": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "noEmit": true,
    "skipLibCheck": true
  },
-  "include": ["**/*.ts"]
+  "include": ["src/**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode

This change made tsc work.

The downside - now TypeScript config is applied only to src directory and files in test are excluded. VSCode could no longer resolve click on #utils.js import in the test directory, and vitest throws an error:

Error: Failed to load url #utils.js (resolved id: #utils.js) in /xxx/subpath-imports-typescript/test/index.spec.ts. Does the file exist?
Enter fullscreen mode Exit fullscreen mode

The first potential solution was to point rootDir to "." instead of src to cover the entire project:

{
  "compilerOptions": {
    "target": "es2021",
    "module": "NodeNext",
-   "rootDir": "src",
+   "rootDir": ".",
    "outDir": "dist",
    "noEmit": true,
    "skipLibCheck": true
  },
-  "include": ["src/**/*.ts"]
+  "include": ["**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode

However, that also didn’t help. When I ran tsc, TypeScript still complained:

Cannot find module '#utils.js' or its corresponding type declarations.
Enter fullscreen mode Exit fullscreen mode

Now the reason was different. When rootDir was set to ".", TypeScript replicated whole project structure inside the dist directory, resulting in:

├── dist
│   ├── src
│   │   ├── index.js
│   │   └── utils.js
│   └── test
│       └── index.spec.js
Enter fullscreen mode Exit fullscreen mode

But the imports field in package.json pointed to dist/utils.js, not dist/src/utils.js.

Attempt #4: Point to dist/src

Okey. I've adjusted the imports field to point to dist/src:

{
  "imports": {
-    "#*": "./dist/*"
+    "#*": "./dist/src/*"
  }
}
Enter fullscreen mode Exit fullscreen mode

This change allowed tsc to work correctly, and VSCode could now navigate to utils.js in src.

However, when I tried to build the project with npm run build, I've got an error:

Cannot find module '#utils.js' or its corresponding type declarations.
Enter fullscreen mode Exit fullscreen mode

The issue was that tsconfig.build.json still had rootDir pointed to src. I've updated rootDir to "." in tsconfig.build.ts as well:

// tsconfig.build.ts
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
-    "rootDir": "src",
+    "rootDir": ".",
    "outDir": "dist",
    "noEmit": false,
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

Now, TypeScript is happy, and I could compile and build the project! 🎉
Yes, it introduced extra nesting inside the dist directory. Previously, the dist directory looked like this:

├── dist
│   ├── index.js
│   └── utils.js
Enter fullscreen mode Exit fullscreen mode

And now with nested src:

├── dist
│   ├── src
│   │   ├── index.js
│   │   └── utils.js
Enter fullscreen mode Exit fullscreen mode

Not a big deal, I'm ready to accept that!

But.. Tests still don't run 🤯

npm run test produces an error:

Failed to load url #utils.js (resolved id: #utils.js) in /xxx/subpath-imports-typescript/test/index.spec.ts. Does the file exist?
Enter fullscreen mode Exit fullscreen mode

It's because only TypeScript is aware of the rootDir and outDir re-mapping. All other tools, like Vitest, didn’t recognize the mapping, leading to runtime errors when trying to resolve the #utils.js from the test directory.

I was stuck. I was going to give up on those mysterious subpath imports and return to TypeScript path aliases. But after reading more documentation and GitHub issues, I found a solution!

Attempt #5: Custom Conditions to the Rescue

According to Node.js docs, you can map single import alias to several locations via object:

"imports": {
  "#*": {
    "condition-a": "./location-a/*",
    "condition-b": "./location-b/*"
  }
}
Enter fullscreen mode Exit fullscreen mode

Keys of that object are called conditions. There are built-in conditions like default, require or import, but also there can be any custom string, defined by user.

I've modified the imports field in package.json to include a custom condition my-package-dev pointing to src and kept default condition pointing to dist:

"imports": {
-  "#*": "./dist/src/*"
+  "#*": {
+    "my-package-dev": "./src/*",
+    "default": "./dist/*"
+  }
}
Enter fullscreen mode Exit fullscreen mode

So, there are two ways for resolving # imports:

  • my-package-dev - tells Node.js to resolve paths from the src directory when condition is enabled (during development)
  • default - fallback option to resolve paths from the dist directory when no specific condition is provided

Note: I intentionally named my condition my-package-dev, not just dev. This is important for library authors. If your consumers run their project with dev condition, your package in node_modules will consider that condition as well and will try to resolve files from src! If you develop end users app, you can use dev or development as a condition name.

Update TypeScript Configuration

Now I need to let TypeScript know about my custom condition. Luckily, tsconfig.json provides a customConditions option for that. I've reverted all the changes made in the previous steps and added customConditions field:

// tsconfig.json
"compilerOptions": {
  "target": "es2021",
  "module": "NodeNext",
+  "customConditions": ["my-package-dev"],
  "noEmit": true,
  "skipLibCheck": true,
}
Enter fullscreen mode Exit fullscreen mode

With this setup, TypeScript correctly resolves subpath imports from the src directory even without rootDir and outDir options. VSCode also correctly navigates to utils.ts location.

Update Vitest Configuration

Vitest also supports providing custom conditions. I've set resolve.conditions in vitest.config.mts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
+  resolve: {
+    conditions: ['my-package-dev'],
+  },
});
Enter fullscreen mode Exit fullscreen mode

After this change, vitest was resolving files from src directory ensuring I check actual code during tests:

Tests pass

Other Tools

For other tools, you should check their documentation on custom conditions support. I've tried to run my project with tsx. As it supports all Node.js flags, I've just provided custom condition via -C flag:

$ npx tsx -C my-package-dev src/index.ts
42
Enter fullscreen mode Exit fullscreen mode

It works.

I can recommend a comprehensive overview of subpath imports support in different tools and IDEs.

Recap

It was a challenging journey to setup subpath imports. Especially when ensuring compatibility across the development flow, testing tools, IDE support and production run 😜

However, the result is successful, and the final setup is not something very complex. I'm confident, subpath imports will eventually become a default way for aliasing in JavaScrip / TypeScript projects. I hope this article saves you time!

I've published a final working example on GitHub, you are welcome to check it out. Thanks for reading and happy coding! ❤️

Top comments (0)