Битва с алиасами!
Битва с алиасами!

TL;DR — используйте custom conditions.

Введение

Subpath imports — это нативная опция в Node.js для задания внутренних алиасов путей в коде.

Например, длинный относительный путь:

import { foo } from '../../../utils.js';

можно упростить до:

import { foo } from '#utils.js';

Это дает два преимущества:

  1. Такой код проще читать

  2. Нет лишних изменений после перемещения файлов

В 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"
  }
  1. npm run tsc — проверка типов всего проекта.

  2. npm run test — запуск тестов для реального кода в src

  3. npm run build — компиляция кода из src в директорию dist

  4. 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.
Благодарю за внимание! ❤️

Комментарии (0)