Цели

  • Настройка абсолютных импортов без расширений для внутренних модулей пакета с использованием Node.js subpath imports, как единого источника псевдонимов:

    • без дублирования в tsconfig path aliases;

    • с поддержкой VSCode IntelliSense - автоимпорт, автодополнение и всплывающие подсказки импортов.

  • Настройка conditional exports - маппинг на исходный код внутренней библиотеки в dev-режиме, для работы Vite(HMR) и VSCode(IntelliSense) с живым кодом без пересборки.

  • Настройка приоритета для автоимпорта публичного API модуля (index.ts) через subpath imports.

Пример

Абсолютные импорты без расширений (package.json + подсказка импорта).
Абсолютные импорты без расширений (package.json + подсказка импорта).

Содержание

Краткий итог

  1. Конфликт между настройками subpath imports для Vite и TypeScript решается добавлением customConditions с fallbacks array для TS. Vite читает default настройки.

  2. Автоимпорт (TSServer. Исправлено в tsgo) добавляет type в импорт значений, решается:

    • отключением опции VSCode js/ts.preferences.preferTypeOnlyAutoImports

    • и заменой verbatimModuleSyntax на правила ESLint в dev-режиме.

  3. Автоимпорт публичного API модуля (index.ts) работает только в extensionless конфигурации.

Вступление

В статье рассмотрены методы решения конфликтов возникающих между инструментами при настройке абсолютных импортов без расширений в режиме ESM.

Изначально весомых аргументов в пользу использования импортов без расширений не было. Планировал проверить техническую возможность вместе с абсолютными импортами. В процессе обнаружил, как получить практическую ценность extensionless конфигурации: настроить приоритет автоимпорта на использование публичного API модуля (index.ts).

Сравнение абсолютных и относительных импортов есть в статьях Максима Земскова и Виталия Потапова.

Не упомянутые настройки: pnpm monorepo, наследуемые конфигурации ESLint и TypeScript, Prettier - можно посмотреть в исходном коде.

Терминология

  • Спецификатор модуля - это строка с путем к файлу или имя пакета в import/re-export после ключевого слова from или аргумент для функции динамического импорта: import().

  • Спецификатор импорта - спецификатор модуля в инструкции import.

  • Разрешение модуля - это процесс поиска файла, на который ссылается спецификатор модуля. В статье чаще используется "Разрешение импортов".

  • Абсолютный импорт (в контексте статьи) - это спецификатор модуля (в импорте или реэкспорте) с хэштегом # настроенный через subpath imports и применимый только внутри пакета.

  • TSServer или Language Server в tsgo (реализующий LSP) далее упоминается как TSServer.

Используемые технологии

  • pnpm monorepo

  • TypeScript (v7.0.2, v6.0.3, v5.x) с поддержкой формата ESM без расширений

  • Vite (v8.0) для разработки и сборки web приложения

  • tsdown для сборки UI библиотеки

  • package.json imports/exports:

    • conditional subpath imports - настройка псевдонимов для внутренних модулей

    • conditional subpath exports - маппинг на исходники UI библиотеки в dev-режиме

  • tsc для проверки типов

  • ESLint v10 с правилами импорта типов

  • VSCode - настройки для подсказки и дополнения путей импортов

Состав монорепозитория

  • apps/web - frontend приложение (React). Сборка: Vite;

  • packages/ui - компилируемая библиотека (React);

  • packages/typescript-config - пакет с базовыми конфигурациями TypeScript для наследования;

  • packages/eslint-config - пакет с базовыми конфигурациями ESLint для наследования.

Особенности инструментов и реализации

В документации и issues инструментов экосистемы часто встречается упоминание реализации алгоритма разрешения модулей Node.js, в т.ч. как “эталонной спецификации”.
Выдержка из документации Node.js:

Когда модуль запрашивается с помощью статических операторов импорта (доступно только в модулях ES) или выражений import() (доступно в модулях CommonJS и ES) алгоритм разрешения модуля:

  • не поддерживает папки в качестве модуля, индекс каталога (./api/index.js) должен быть указан полностью;

  • не выполняет поиск расширения. Расширение файла должно быть указано, если спецификатор является относительным или абсолютным URL-адресом файла.

Несмотря на требования Node.js, каждый инструмент по-своему реализует разрешение модулей. Для поддержки спецификаторов с абсолютными путями для внутренних модулей без расширений требуется согласовать настройки.

TypeScript, Turborepo и др. предлагают использовать subpath imports вместо paths aliases для настройки абсолютных импортов внутренних модулей:

(TS) Both libraries and apps can consider package.json “imports” as a standard replacement for convenience paths aliases.

(Turbo) Use Node.js subpath imports instead of TypeScript compiler paths

VSCode IntelliSense получает подсказки для импортов через TSServer.
TSServer получает настройки typescript-language-features (VSCode extension) из .vscode/settings.json.

TSServer, tsc, Vite/tsdown (Rolldown) читают:

  • настройки TypeScript (tsconfig.json),

  • поля type, exports и imports в package.json

  • собственные файлы конфигурации (vite.config.ts, tsdown.config.ts).

TypeScript (TS) и Vite в режиме разработки (vite dev) обращаются к актуальным исходникам компилируемых библиотек (ui). Для этого у библиотеки в поле exports package.json точки входа по условию "@repo/source" указывают на исходники.

Условия указываются в:

  • tsconfig.json: compilerOptions.customConditions

  • vite.config.ts: resolve.conditions.

Tsdown работает в режиме транспиляции кода ui библиотеки из ts в js (опция unbundle: true) и формирует отдельный файл для каждого модуля, что помогает Vite при сборке эффективно резать неиспользуемый код (tree-shaking), а в dev-режиме применять HMR при изменениях в коде библиотеки.

Общие настройки

Для использования формата ESM необходимо указать в пакетах type: module.

package.json:

  "name": "@repo/package-name",
  "type": "module"

Vite

Vite не читает customConditions из tsconfig.json. Чтобы в режиме разработки (vite dev) Vite обращался к актуальным исходникам внутренних компилируемых пакетов (ui) необходимо добавить условие в resolve.conditions:

vite.config.ts

export default defineConfig(({ command }) => {
  const customConditions: string[] = []
  // 'serve': during dev (vite command)
  if (command === 'serve') {
    customConditions.push('@repo/source')
  }
  return {
    plugins: [react()],
    resolve: {
      conditions: customConditions,
    },
  ...

В режиме сборки (vite build) Vite по умолчанию использует условие default и обращается к скомпилированному коду (см. Настройки компилируемых пакетов).
Полный список используемых Vite условий в зависимости от платформы и типа импорта в документации [Rolldown] (https://rolldown.rs/reference/InputOptions.resolve#conditionnames).

TypeScript

Наследуемые базовые настройки компилятора TypeScript для всех пакетов указаны в packages\typescript-config\base.json
Настройки TypeScript используемые Vite.

Основные настройки TypeScript, влияющие на разрешение модулей:

  "compilerOptions": {
    // Компиляция в формат ESM с поддержкой современных стандартов ECMAScript
    "module": "esnext",
    // Указывает TS, что разрешение импортов выполняет бандлер. Не требует расширений.
    "moduleResolution": "bundler",
    // Баг TSServer (исправлено в tsgo) с verbatimModuleSyntax:
    // автоимпорт парных <Tag></Tag> даёт import type вместо import. Работает только <Tag /> или <Tag>.
    // Аналогичная проблема при использовании настройки VSCode:
    // `js/ts.preferences.preferTypeOnlyAutoImports: true`
    "verbatimModuleSyntax": false,
    // Если outDir не указать, то TS->TSServer->VSCode будет предлагать расширение: .js
    "outDir": "./dist",
    "rootDir": "./src",
    // Учитывать условия в полях exports/imports (package.json) при разрешении путей импортов
    "customConditions": ["@repo/source", "@repo/ts-subpath-imports"]
  }

В tsconfig.app.json можно отключить verbatimModuleSyntax - указать false.

verbatimModuleSyntax запрещает импорт/экспорт типов без ключевого слова type.

В dev-режиме валидация осуществляемая verbatimModuleSyntax заменяется на правила ESLint с поддержкой автоисправления:

  • consistent-type-imports

  • consistent-type-exports

  • no-import-type-side-effects

См. packages\eslint-config\typescript-eslint.js.

Для проверки перед сборкой в tsc передается tsconfig.build.json с "verbatimModuleSyntax": true

VSCode IntelliSense

TSServer учитывает настройки VSCode для разрешения импортов. Чтобы TSServer возвращал в VSCode абсолютные пути без расширений в .vscode/settings.json необходимо указать настройки:

  "js/ts.preferences.importModuleSpecifier": "non-relative",
  "js/ts.preferences.importModuleSpecifierEnding": "minimal",
  // Баг TSServer (исправлено в tsgo) с preferTypeOnlyAutoImports:
  // автоимпорт парных <Tag></Tag> даёт import type вместо import. Работает только <Tag /> или <Tag>.`
  // Аналогичная проблема с опцией TypeScript: "verbatimModuleSyntax: true".
  "js/ts.preferences.preferTypeOnlyAutoImports": false,
  // В зависимости от style guide.
  "js/ts.preferences.quoteStyle": "single",

Примечание: опция preferTypeOnlyAutoImports добавлена как альтернатива verbatimModuleSyntax.

Настройка conditional subpath exports внутренних пакетов/библиотек

В терминологии Turborepo внутренние пакеты в зависимости от стратегии компиляции делятся на:

  • Just-in-Time packages

  • Compiled packages.

JIT packages компилируются использующим их приложением из исходников.
Compiled packages компилируются сборщиком tsdown, а сборщик приложения (Vite) использует скомпилированный код (в нашем случае - только транспилированный).

Настройки JIT пакетов

Точки входа exports всегда указывают на исходники.

package.json:

  "exports": { ".": "./src/index.ts" }

Настройки компилируемых пакетов

Точки входа exports с условиями: “default” и “types” указывают на скомпилированный код и декларации типов.
Условие “@repo/source” указывает на исходники для работы Vite и IntelliSense с актуальным кодом.
Важно добавить “@repo/source” выше default, поскольку TypeScript и бандлеры (Vite, tsdown) по умолчанию включают в условия “default”, а по спецификации Node.js приоритет сопоставления от первого к последнему ключу объекта.

(TypeScript) When resolving through conditional “exports”, TypeScript always matches the “types” and “default” conditions if present.

package.json:

  "exports": {
    ".": {
      "@repo/source": "./src/index.ts",
      "types": "./dist/index.d.mts",
      "default": "./dist/index.mjs"
    },
  }

Настройка абсолютных импортов с использованием conditional subpath imports

package.json пакетов web и ui:

  "imports": {
    "#*": {
      "@repo/ts-subpath-imports": [
        "./src/*/index.js",
        "./src/*.js"
      ],
      "default": "./src/*"
    }
  },

Индексный файл указывается первым для автоимпорта публичного API модуля.

Почему в целевых путях subpath imports требуются расширения .js: "./src/*/index.js", "./src/*.js"?

TypeScript не выполняет поиск расширения: либо спецификатор должен быть с расширением, либо целевой путь subpath imports.
Если расширение .js указано в subpath imports - TS добавляет к extensionless спецификатору .js и запускает алгоритм поиска файла реализации: File extension substitution

Такой подход позволяет не дублировать в fallbacks array (см. ниже) целевые пути с различными расширениями, как это рекомендуется в документации Turborepo для поддержки импортов без расширений.

Если не указать, то TSServer в автоимпорте и подсказках генерирует спецификаторы импорта с расширениями.

Для чего нужно условие @repo/ts-subpath-imports?

TS понимает массив вариантов (fallbacks array) указанный в полях package.json exports / imports. Vite (oxc-resolver под капотом) придерживается спецификации Node.js и принимает только строку или первый элемент массива для целевых путей subpath imports, issues:

Очевидно было бы добавить строку "./src/*" в начало fallbacks array, но тогда автоимпорты будут с расширением .js.

Поэтому TypeScript будет читать subpath imports через условие @repo/ts-subpath-imports, а Vite - через default.

Особенности работы TSServer при использовании шаблонов (subpath patterns)

Таблица. Особенности шаблонов #*, #src/*, #/*

Шаблон

Пример

TS, Node

Подсказки и автоимпорт

Дополнение пути

#*

import { App } from ‘#app/App’

TS: v5.4.0, Node.js: v14.6.0, v12.19.0

+

с paths

tsgo: v7.0.2

+

с paths

#src/*

import { App } from ‘#src/app/App’

TS: v5.7.2, Node.js: v25.4.0, v24.14.0

+

с paths

tsgo

+

с paths

#/*

import { App } from ‘#/app/App’

TS: v6.0.2, Node.js: v25.4.0, v24.14.0

с paths

с paths

tsgo

+

с paths

  • Дополнение пути (path completion) - подсказка сегментов пути IntelliSense при ручном вводе работает только с дублированием в paths.

  • Для шаблона #/* до tsgo (ts 7.0.2) необходимо указать compilerOptions.paths в web/tsconfig.app.json и ui/tsconfig.app.json, иначе возвращается относительный путь:

  "compilerOptions": {
    "paths": {
      "#/*": ["./src/*"]
    }
  }

Приоритет автоимпорта публичного API модуля (index.ts) через subpath imports

Предположим, что модуль предоставляет публичный API через index.ts, чтобы скрыть внутреннюю структуру и детали реализации (например в методологии FSD):

  • src/shared/api/

    • api.ts - файл, где объявлена переменная api;

    • index.ts - Публичный API модуля, с реэкспортом переменной api.

Логично, чтобы автоимпорт при генерации спецификатора импорта отдавал приоритет directory module и возвращал:

  • или папку содержащую файл index.ts: import { api } from '#shared/api'

  • или полный путь к index.ts (для настроек с расширениями): import { api } from '#shared/api/index.ts'

Настройка без расширений: необходимо вставить целевой путь с index.js первым в fallbacks array subpath imports - это гарантирует приоритет разрешения модулей:

  • есть файл index.ts - автоимпорт указывает на директорию: import { api } from '#shared/api'

  • нет - на файл c объявлением: import { api } from '#shared/api/api'

Конфигурацию с обязательными расширениями настроить не удалось - автоимпорт отдает приоритет спецификатору импорта, указывающему на файл с объявлением: #shared/api/api.ts.

Дополнение

Проверка tsc перед сборкой

Перед сборкой необходимо проверить:

  • соблюдение требований verbatimModuleSyntax (отключенной в dev-режиме);

  • корректность разрешения модулей с принудительно удаленными paths.

Общие настройки укажем в наследуемой конфигурации packages/typescript-config/build.json:

  "compilerOptions": {
    "verbatimModuleSyntax": true,
    // The goal is to stop using path aliases
    "paths": null
  }

Пустой tsconfig.build.json в каждом пакете наследует “tsconfig.app.json” и “@repo/typescript-config/build.json”.

Скрипты запуска tsc в package.json пакетов web и ui:
package.json

  "typecheck": "tsc -p tsconfig.build.json && tsc -p tsconfig.node.json",

Запуск из корня проекта: pnpm typecheck.

Примечания

  1. Если имя папки импортируемого компонента частично совпадает с именем папки использующего и обе папки находятся на одном уровне, то TSServer игнорирует реэкспорт в barrel file (index.ts) и предлагает путь к tsx, например если есть два компонента: ./src/Icon и ./src/IconButton - при импорте Icon из IconButton IDE предлагает путь ‘#Icon/Icon’, вместо ‘#Icon’. Аналогичное поведение при замене имени папки Icon на: I, Ic, Ico, IconB, и т.д. Возможно - issue TypeScript. Временное решение: import-x/no-useless-path-segments

  2. Циклическая ссылка иногда вешает ESLint - уводит в бесконечную рекурсию: в index.ts файлах нужно использовать относительные пути: ./src/components/Icon/index.ts

      // export { Icon } from '#components/Icon/Icon' - hangs ESLint.
      export { Icon } from './Icon.tsx'
    
  3. При смене шаблона подпути (#* -> #/*) в ui/package.json необходимо поправить реэкспорты в корневом index.ts, иначе автоимпорт возвращает “странные” спецификаторы импорта.

Issues открытые в репозитории TypeScript

  1. Автоимпорт игнорирует index.ts, если имя папки является префиксом имени импортирующего файла (#64029)

  2. Автоимпорт отдаёт приоритет файлам, идущим по алфавиту перед index.ts внутри папки (#64034)

Заключение

Vite (oxc-resolver) при использовании subpath pattern: "#*": "./src/* разрешает модули перебирая расширения и проверяя наличие файла на диске (в т.ч. подставляя индексные файлы: index.ts{x}).
Опции Rolldown/oxc_resolver: extensions, mainFiles.

TypeScript, строго придерживаясь спецификации, не ищет расширения, но предоставляет альтернативу: массив вариантов для проверки (fallbacks array или alternatives в терминологии Webpack)

В issues TypeScript и Vite есть дискуссии (2024г.):

Поскольку алгоритмы работают давно - нет причин ожидать breaking changes без альтернативы.

Vite, tsdown, tsc отлично понимают subpath imports без paths. Paths требуется только для TSServer.

Можно использовать любой из шаблонов subpath imports, с учетом:

  • #*, #src/* - без дублирования в path;

  • #/* - Доступен с TS 6.0.2, но TSServer IntelliSense требует paths до TS 7 (tsgo);

  • path completion доступен только с дублированием в paths.

Настройки для всех шаблонов идентичны. Можно добавлять paths при необходимости path completion (отключается перед сборкой в tsconfig.build.json).

Отсутствие дополнения пути в import при ручном вводе не критично (IMHO) при включении в IDE автоимпорта.
TypeScript в процессе перехода на Go. Многие issues связанные с TypeScript language service заморожены, например: Intellisense fails to detect package.json import paths #61504

Подход успешно протестирован на backend (API) приложении: fastify + prisma с транспиляцией tsdown и запуском через tsx --conditions.

Несмотря на предложение рассматривать subpath imports как единый источник правды псевдонимов - инструменты используют его по разному и очень не хватает совместно поддерживаемой документации или руководства по настройке от авторов экосистемы Node.js.

Похожие мысли в статье Марка Эриксона о внедрении поддержки ESM в Redux.

Систематизированный список проблем настройки инструментов в monorepo с поддержкой ESM/TypeScript.
Авторы предлагают добавить новые настройки в package.json для исправления ситуации, например:

  "conditions": ["development"],
  "extensions": [".js", ".jsx", ".ts", ".tsx"]

P.S. Спасибо @Lotgyero за корректуру и наводящие мысли.

Ссылки

Документация

  1. Node.js subpath exports / imports / patterns / conditions

  2. TypeScript. Раздел paths: Both libraries and apps can consider package.json “imports” as a standard replacement for convenience paths aliases.

  3. TypeScript. Разрешение импортов с использованием package.json imports

  4. TypeScript. Почему наименования customConditions должны быть уникальны

  5. Vite. Какие опции tsconfig.json использует Vite

  6. Rolldown. Condition names to use when resolving exports in package.json

  7. Настройки разрешения модулей:

    1. Vite

    2. Rolldown

    3. oxc_resolver

  8. Turborepo. Раздел TypeScript: Use Node.js subpath imports instead of TypeScript compiler paths

  9. Support seamless cross-package development for monorepos #58626. Ernesto Stifano, Aniello Falcone

Статьи

Обновления

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