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
- Example Project
- Attempt #1: Follow Node.js Docs
- Attempt #2: Use distinstead ofsrc
- Attempt #3: Set rootDirandoutDir
- Attempt #4: Point to dist/src
- Attempt #5: Custom Conditions to the Rescue
- Recap
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';
You can set up a subpath import to simplify this to:
import { foo } from '#utils.js';
Two main benefits:
- easier to read
- 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
This is a fairly typical setup:
- 
srcandtestcontain the source code and unit tests respectively
- 
tsconfig.jsonfor type-checking the entire project
- 
tsconfig.build.jsonfor compiling the source code fromsrcinto thedistdirectory
- 
vitest.config.mtsfor 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;
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);
});
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"
  }
- 
npm run tsc- type-check the entire project
- 
npm run test- run tests against the actual code insrc
- 
npm run build- compile the code fromsrcinto thedistdirectory
- 
npm start- run the project from thedistdirectory
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/*"
+  },
}
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';
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'
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/*"
  },
}
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.
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"]
}
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.
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"]
}
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?
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"]
}
However, that also didn’t help. When I ran tsc, TypeScript still complained:
Cannot find module '#utils.js' or its corresponding type declarations.
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
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/*"
  }
}
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.
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"]
}
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
And now with nested src:
├── dist
│   ├── src
│   │   ├── index.js
│   │   └── utils.js
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?
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/*"
  }
}
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/*"
+  }
}
So, there are two ways for resolving # imports:
- 
my-package-dev- tells Node.js to resolve paths from thesrcdirectory when condition is enabled (during development)
- 
default- fallback option to resolve paths from thedistdirectory when no specific condition is provided
Note: I intentionally named my condition
my-package-dev, not justdev. This is important for library authors. If your consumers run their project withdevcondition, your package innode_moduleswill consider that condition as well and will try to resolve files fromsrc! If you develop end users app, you can usedevordevelopmentas 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,
}
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'],
+  },
});
After this change, vitest was resolving files from src directory ensuring I check actual code during tests:
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
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)