TL;DR — используйте custom conditions.
Введение
Subpath imports — это нативная опция в Node.js для задания внутренних алиасов путей в коде.
Например, длинный относительный путь:
import { foo } from '../../../utils.js';
можно упростить до:
import { foo } from '#utils.js';
Это дает два преимущества:
Такой код проще читать
Нет лишних изменений после перемещения файлов
В TypeScript существует старый способ настройки алиасов через опцию paths. Это хорошо работает для TypeScript, но не работает для Node.js, потому что ему ничего не известно об этих алиасах. Чтобы запускать скомпилированный код, нужно использовать сторонние пакеты (tsconfig-paths, tsc-alias).
Хорошая новость в том, что начиная с версии 5.4, TypeScript добавил поддержку subpath imports. Но на практике, внедрение subpath imports в мой TypeScript проект оказалась непростой задачей. Далее расскажу о разных подходах и поделюсь окончательным решением.
Пример проекта
Для экспериментов я взял следующий проект:
my-project
├── src
│ ├── index.ts
│ └── utils.ts
├── test
│ └── index.spec.ts
├── package.json
├── tsconfig.build.json
├── tsconfig.json
└── vitest.config.mts
Это относительно типичная структура:
src
иtest
- исходный код и юнит-тесты соответственноtsconfig.json
- для проверки типов всего проектаtsconfig.build.json
- для компиляции исходного кода изsrc
в директориюdist
.vitest.config.mts
- для запуска юнит-тестов с помощью vitest.
Изначально, проект использует классические относительные пути. src/index.ts
импортирует константу foo
из ./utils.js
:
// src/index.ts
import { foo } from './utils.js';
console.log(foo);
// src/utils.ts
export const foo = 42;
В директории test
также используется импорт utils по относительному пути:
// test/index.spec.ts
import { foo } from '../src/utils.js';
test('foo is 42', () => {
expect(foo).toBe(42);
});
В package.json
содержится несколько скриптов npm, которые в начальном состоянии проекта успешно выполняются:
"scripts": {
"tsc": "tsc",
"test": "vitest run",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/index.js"
}
npm run tsc
— проверка типов всего проекта.npm run test
— запуск тестов для реального кода вsrc
npm run build
— компиляция кода изsrc
в директориюdist
npm start
— запуск проекта из директорииdist
Я буду использовать эти команды как чеклист в дальнейших экспериментах с subpath imports. Также я проверяю в VSCode, что по клику на импорте из utils редактор открывает корректный файл из src
.
Подход 1: Следуем документации Node.js
Первым шагом я решил просто следовать примерам из документации Node.js. Я добавил поле imports
в package.json
и настроил алиас на директорию src
.
// package.json
{
"name": "subpath-imports-typescript",
+ "imports": {
+ "#*": "./src/*"
+ },
}
и использовал этот алиас в src/index.ts
и 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';
После изменений все команды работали, кроме npm start
, который завершался с ошибкой:
> node dist/index.js
node:internal/modules/cjs/loader:1110
throw e;
^
Error: Cannot find module '/projects/subpath-imports-typescript/src/utils.js'
Проблема в том, что Node.js ищет #utils.js
в директории src
вместо dist
. Но в src
лежит нескомпилированный utils.ts
, поэтому и ошибка.
Подход 2: Используем dist вместо src
Чтобы исправить это, я изменил поле imports
в package.json
, указав dist
вместо src
:
{
"name": "subpath-imports-typescript",
"imports": {
- "#*": "./src/*"
+ "#*": "./dist/*"
},
}
Сначала казалось, что это сработало. Однако, как только я удалил директорию dist
, все сломалось! VSCode больше не мог найти модуль #utils.js
, и TypeScript выдал ошибку:
Cannot find module '#utils.js' or its corresponding type declarations.
Причина в том, что теперь TypeScript и VSCode не могут зарезолвить алиас на dist
, поскольку директории dist
попросту нет.
Проблема "курицы и яйца" — исходные файлы ссылаются на скомпилированные файлы, чтобы получить скомпилированные файлы 🤪
Для такого случая документация TypeScript рекомендует установить параметры rootDir
и outDir
в tsconfig.json
.
Подход 3: Настройка rootDir и outDir
Я добавил параметры rootDir
и outDir
в tsconfig.json
:
// tsconfig.json
{
"compilerOptions": {
"target": "es2021",
"module": "NodeNext",
+ "rootDir": "src",
+ "outDir": "dist",
"noEmit": true,
"skipLibCheck": true
},
"include": ["**/*.ts"]
}
Идея тут в следующем: когда TypeScript знает, где находятся исходные и скомпилированные файлы, он может корректно перенаправлять subpath imports на исходные файлы.
Например, #utils.js
будет сначала зарезолвлен в ./dist/utils.js
, а затем перенаправлен на ./src/utils.ts
.
Однако при запуске tsc
TypeScript выдал следующую ошибку:
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.
Для решения этой проблемы мне пришлось сузить параметр include
до src/**/*.ts
:
{
"compilerOptions": {
"target": "es2021",
"module": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"noEmit": true,
"skipLibCheck": true
},
- "include": ["**/*.ts"]
+ "include": ["src/**/*.ts"]
}
После этого tsc
успешно выполнить сборку!
Однако возникла другая проблема. Теперь конфигурация TypeScript применялась только к директории src
, и файлы в директории test
выпадают. Если кликнуть на импорт из #utils.js
внутри папки test
, VSCode никуда не переходит.
Vitest также ожидаемо выдавёт ошибку:
Error: Failed to load url #utils.js (resolved id: #utils.js) in /xxx/subpath-imports-typescript/test/index.spec.ts. Does the file exist?
Чтобы вернуть директорию test
обратно в проект, я поменял rootDir
на корневой путь "."
:
{
"compilerOptions": {
"target": "es2021",
"module": "NodeNext",
- "rootDir": "src",
+ "rootDir": ".",
"outDir": "dist",
"noEmit": true,
"skipLibCheck": true
},
- "include": ["src/**/*.ts"]
+ "include": ["**/*.ts"]
}
Однако это тоже не помогло. Запуск tsc
снова выдал ошибку:
Cannot find module '#utils.js' or its corresponding type declarations.
Но причина уже другая. После установки rootDir
на "."
, TypeScript начал дублировать всю структуру проекта внутри директории dist
:
├── dist
│ ├── src
│ │ ├── index.js
│ │ └── utils.js
│ └── test
│ └── index.spec.js
Но поле imports
в package.json
указывало на dist/utils.js
, а не на dist/src/utils.js
.
Подход 4: Указываем путь к dist/src
Хорошо! Я снова поменял imports
в package.json
, указав путь к dist/src
:
{
"imports": {
- "#*": "./dist/*"
+ "#*": "./dist/src/*"
}
}
После этого изменения, проверка типов через tsc
стала проходить, и VSCode корректно навигировал на utils.js
в директории src
.
Однако не работала сборка. При запуске npm run build
выдавалась ошибка:
Cannot find module '#utils.js' or its corresponding type declarations.
Причина была в том, что tsconfig.build.json
все ещё указывал rootDir
как src
. Я обновил rootDir
на "."
и в tsconfig.build.ts
:
// tsconfig.build.ts
{
"extends": "./tsconfig.json",
"compilerOptions": {
- "rootDir": "src",
+ "rootDir": ".",
"outDir": "dist",
"noEmit": false,
},
"include": ["src"]
}
Теперь TypeScript отрабатывал без ошибок и на проверке типов, и на сборке проекта! 🎉
Да, появился минус - дополнительная вложенность внутри директории dist
. Ранее структура dist
выглядела так:
├── dist
│ ├── index.js
│ └── utils.js
Теперь же с вложенной src
стало так:
├── dist
│ ├── src
│ │ ├── index.js
│ │ └── utils.js
Я готов смириться с этим!
Но... тесты не запускаются 🤯
Выполнение npm run test
выдает ошибку:
Failed to load url #utils.js (resolved id: #utils.js) in /xxx/subpath-imports-typescript/test/index.spec.ts. Does the file exist?
Причина в то, что о перенаправлении outDir -> rootDir
знает только TypeScript. Все остальные инструменты, и в частности Vitest, не учитывают этот маппинг и пытаются зарезолвить #utils.js
в директории dist
, которая отсутствует. Даже если не удалять dist
, это все равно работает некорректно: запуская тесты, мы ожидаем проверку актуального кода в src
, а не в dist
.
На этом этапе я почти сдался. Я был готов отказаться от этих subpath imports и вернуться к старым добрым TypeScript paths. Но, почитал документацию и несколько ишьюс на GitHub, я нашел решение.
Подход 5: Спасение в custom conditions
Согласно документации Node.js, для одного алиаса можно указать нескольких вариантов пути с помощью объекта:
"imports": {
"#*": {
"condition-a": "./location-a/*",
"condition-b": "./location-b/*"
}
}
Ключи этого объекта называются custom conditions. Они есть встроенные, такие как default
, require
или import
, но также можно использовать любую строку, заданную пользователем.
Я поменял imports
в package.json
на объект с двумя условиями:
"imports": {
- "#*": "./dist/src/*"
+ "#*": {
+ "my-package-dev": "./src/*",
+ "default": "./dist/*"
+ }
}
Теперь есть два способа резолва #
импортов:
my-package-dev
— резолвит пути изsrc
при включенном condition (для разработки).default
— фолбэк на директориюdist
для всех остальных случаев
Примечание: Я намеренно назвал условие
my-package-dev
, а не простоdev
. Это важно для авторов npm-пакетов. Если ваши пользователи запустят свой проект с условиемdev
, ваша библиотека вnode_modules
также будет учитывать это условие и попытается резолвить файлы изsrc
! Если вы разрабатываете приложение, можете использоватьdev
илиdevelopment
.
Обновление конфигурации TypeScript
Теперь нужно прокинуть эти custom conditions в TypeScript. К счастью, как раз для этого в tsconfig.json
есть опция customConditions. Я откатил все изменения, сделанные на предыдущих этапах, и добавил поле customConditions
:
// tsconfig.json
"compilerOptions": {
"target": "es2021",
"module": "NodeNext",
+ "customConditions": ["my-package-dev"],
"noEmit": true,
"skipLibCheck": true,
}
После этого TypeScript корректно резолвит импорты из src
, даже без настроек rootDir
и outDir
. VSCode также корректно переходит к файлу src/utils.ts
.
Обновление конфигурации Vitest
Vitest также поддерживает custom conditions. Нужно указать их в опции resolve.conditions
в vitest.config.mts
:
import { defineConfig } from 'vitest/config';
export default defineConfig({
+ resolve: {
+ conditions: ['my-package-dev'],
+ },
});
Теперь Vitest резолвит импорты из src
, значит тесты проверяют актуальный код:
Другие инструменты
Поддержка custom conditions в других инструментах может отличаться, нужно смотреть документацию. Я попробовал запустить проект с помощью tsx. Он проксирует флаги в Node.js, поэтому я передал my-package-dev
через флаг -C
:
$ npx tsx -C my-package-dev src/index.ts
42
Это работает.
На хабре есть отличный обзор поддержки subpath imports в различных инструментах и IDE.
Итог
Это был непростой путь настройки subpath imports. Особенно с учетом поддержки при разработке, в инструментах тестирования, в IDE и в продакшен 😜
Однако результат есть, и итоговое решение не выглядит слишком сложным. Я думаю, что со временем subpath imports станут стандартным методом для алиасов путей в JavaScrip / TypeScript проектах. Надеюсь, эта статья сэкономит вам время.
Финальный работающий пример доступен на GitHub.
Благодарю за внимание! ❤️