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 ofsrc
- Attempt #3: Set
rootDir
andoutDir
- 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:
-
src
andtest
contain the source code and unit tests respectively -
tsconfig.json
for type-checking the entire project -
tsconfig.build.json
for compiling the source code fromsrc
into thedist
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;
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 fromsrc
into thedist
directory -
npm start
- run the project from thedist
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/*"
+ },
}
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 thesrc
directory when condition is enabled (during development) -
default
- fallback option to resolve paths from thedist
directory 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 withdev
condition, your package innode_modules
will consider that condition as well and will try to resolve files fromsrc
! If you develop end users app, you can usedev
ordevelopment
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,
}
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)