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 dist instead of src
Attempt #3: Set rootDir and outDir
Attempt #4: Point to dist/src
Attempt #5: Custom Conditions to the Rescue Update TypeScript Configuration Update Vitest Configuration Other Tools
Update TypeScript Configuration
Update Vitest Configuration
Other Tools
Recap
Update TypeScript Configuration
Update Vitest Configuration
Other Tools
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:
You can set up a subpath import to simplify this to:
Two main benefits:
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 v4.8 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:
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 does not use subpath imports. src/index.ts uses relative path to import constant from utils:
In the test directory there is also import of utils by relative path:
There are a few npm scripts in package.json, that successfully run in the initial project state:
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.
and used that alias in src/index.ts and test/index.spec.ts:
After making these changes, all commands worked except the npm start, which failed with the following error:
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:
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:
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 in tsconfig.json.
Attempt #3: Set rootDir and outDir
I've added the rootDir and outDir options:
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:
To resolve this, I had to narrow the include option to 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:
The first potential solution was to point rootDir to "." instead of src to cover the entire project:
However, that also didn’t help. When I ran tsc, TypeScript still complained:
Now the reason was different. When rootDir was set to ".", TypeScript replicated whole project structure inside the dist directory, resulting in:
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:
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:
The issue was that tsconfig.build.json still had rootDir pointed to src. I've updated rootDir to "." in tsconfig.build.ts as well:
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:
And now with nested src:
Not a big deal, I'm ready to accept that!
But.. Tests still don't run 🤯
npm run test produces an error:
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:
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:
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:
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:
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:
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 Node.js 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! ❤️