Paths в TypeScript: почему alias компилируется, но не запускается
Автор: Казачкин Даниил Михайлович · Обновлено
Настройка paths помогает TypeScript найти исходный файл по короткому имени импорта. Она не заставляет Node.js понимать такое имя и не переписывает его в выходном JavaScript. Воспроизведём ошибку на двух файлах, проследим путь от исходника до запуска и исправим причину без подавления диагностики.
Цель и подготовка
Вы научитесь разделять разрешение модулей в редакторе, при сборке и при выполнении. До урока нужно понимать exports/imports и уметь собрать небольшой проект с tsconfig. Нужны Node.js 24, pnpm и TypeScript 6. Пример выполняется в новой папке, чтобы существующие плагины и настройки сборщика не скрывали наблюдаемое поведение.
Два участника читают один импорт
Когда редактор подчёркивает неизвестный модуль, это результат поиска компилятора. Когда Node.js сообщает, что пакет не найден, поиск выполняет уже среда запуска. Между этими этапами может работать сборщик, который превращает импорты в свои выходные файлы. У каждого участника должны быть согласованные правила, но они не становятся одинаковыми от наличия одного tsconfig.
Начните с package.json и установки инструментов. Поле type нужно для запуска сгенерированных .js как ECMAScript-модулей.
{
"private": true,
"type": "module"
}pnpm add -D typescript@6 @types/node@24Сохраните следующую полную конфигурацию в tsconfig.json. Значение paths относится к разрешению исходников. Отсутствие baseUrl здесь намеренное: относительная цель задаётся от каталога конфигурации, а в TypeScript 6 сам baseUrl объявлен устаревающим.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"noEmitOnError": true,
"rootDir": "src",
"outDir": "dist",
"paths": {
"@app/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"]
}Создайте src/format.ts. Функция специально проста: вычисления не должны отвлекать от исследования импорта.
export function formatLabel(value: string): string {
return value.trim().toUpperCase();
}Файл src/main.ts использует alias. Расширение .js соответствует будущему выходному файлу; компилятор при поиске исходников умеет сопоставить его с .ts.
import { formatLabel } from '@app/format.js';
console.log(formatLabel(' урок '));pnpm exec tsc -p tsconfig.json
node dist/main.jsПервая команда завершается успешно. Вторая завершается ошибкой загрузки модуля: Node.js не находит пакет @app/format.js. Конкретная формулировка зависит от версии, а код ошибки для этого примера — ERR_MODULE_NOT_FOUND. Это ожидаемое наблюдение, а не предложение оставлять неработающий проект.
Изучаем артефакт, а не только зелёную проверку
Откройте dist/main.js: в нём остался импорт из @app/format.js. TypeScript нашёл типы и реализацию для проверки, но не заменил текст спецификатора на относительный путь. Именно этот текст получил Node.js. Если бы компилятор переписывал paths, выход выглядел бы иначе, однако такая обязанность у этой настройки отсутствует.
Правильная точка диагностики — граница между инструментами. --traceResolution объясняет решения TypeScript, но не является трассировкой запуска Node.js. После изменения конфигурации полезно проверить обе вещи: куда компилятор пришёл по импорту и какой путь реально оказался в готовом JavaScript.
Исправляем проект на относительный ESM-импорт
Для двух локальных файлов alias не нужен. Замените всё содержимое src/main.ts следующим фрагментом и повторите две команды сборки и запуска.
import { formatLabel } from './format.js';
console.log(formatLabel(' урок '));Теперь вывод — УРОК. Относительный путь в исходнике согласован с относительным расположением выходных файлов. Это решение не требует дополнительного загрузчика, плагина или преобразования после tsc. Импорт ./format без расширения в таком NodeNext-проекте был бы другим соглашением, которое обычный ESM-запуск Node.js не обязан поддерживать.
Если короткие импорты действительно нужны в большом приложении, настройте механизм, который понимает фактическая среда: например, подходящие пакетные импорты Node.js или resolver сборщика. Затем отдельно проверьте тестовый runner и серверную сборку. Конкретный синтаксис зависит от выбранного инструмента; запись в paths сама по себе не доказывает, что весь этот маршрут согласован.
Рабочий короткий импорт через package.json imports
В том же проекте можно сохранить короткое имя без дополнительных загрузчиков. Добавьте в существующий package.json поле imports, сохранив установленные devDependencies и lockfile. Ниже показаны относящиеся к запуску поля; ключ начинается с #, как требует механизм внутренних пакетных импортов Node.js.
{
"private": true,
"type": "module",
"imports": {
"#app/*": "./dist/*.js"
}
}Замените src/main.ts следующим кодом. Остальные исходники и параметры rootDir/outDir остаются теми же. Прежний paths для @app/* больше не используется и может быть удалён из compilerOptions.
import { formatLabel } from '#app/format';
console.log(formatLabel(' урок '));Повторите сборку и запуск: результат — УРОК. В готовом main.js сохраняется #app/format, но теперь Node.js знает, как найти файл через package.json. TypeScript в режиме NodeNext также понимает imports: для локального проекта он сопоставляет выходной путь с исходником, используя rootDir и outDir. Поэтому предварительно существующий dist не нужен для разрешения этого импорта при чистой сборке.
Рабочее соглашение здесь хранится в package.json, который понадобится и при доставке приложения. Оно действует внутри данного пакета и не превращает внутренние файлы в публичный API для соседних пакетов. Такой вариант подходит выбранному Node.js-проекту; поддержку тех же правил в другом сборщике следует проверять отдельно.
Где aliases скрывают настоящую проблему
Не направляйте paths в случайные внутренние файлы установленной библиотеки ради обхода её публичных экспортов. Такое сопоставление может обойти правила package.json и дать программе доступ к файлам, которые пакет не обещает поставлять как стабильный API. Обновление зависимости тогда ломает импорт даже при прежнем публичном интерфейсе.
В монорепозитории aliases на чужие исходники также могут скрыть отсутствующую зависимость между пакетами. Проверка проходит благодаря общей папке, а отдельно упакованный пакет не содержит нужного файла. Для настоящих пакетов предпочтительнее оформленная зависимость и публичная точка входа. Project references решает другую задачу — порядок проверки и сборки связанных проектов — и не заменяет механизм runtime-импортов.
Типичные ошибки
Частая реакция на runtime-сбой — переключить moduleResolution на bundler. Это может убрать часть ограничений при проверке, но не добавляет сборщик в команду node. Настройка должна описывать существующую цепочку исполнения. Иначе диагностика становится тише, а выходной артефакт остаётся неработающим.
Вторая ошибка — проверять только dev-сервер. Он способен обслуживать исходники и применять дополнительные преобразования, которых нет в production-команде. Для импорта важен минимальный поведенческий тест: собрать проект тем способом, которым он доставляется, и запустить полученный файл без возможностей редактора.
Практика с разбором
Добавьте src/report.ts, который импортирует formatLabel и экспортирует reportTitle. Пусть main вызывает только reportTitle. Сначала используйте alias на одном из двух переходов, затем соберите и найдите точный выходной файл, в котором осталось короткое имя. Предскажите, на каком шаге загрузки произойдёт сбой.
В исправленном варианте main импортирует ./report.js, а report — ./format.js; оба пути сохраняются и работают внутри dist. Вывод можно оставить прежним. Проверка важна тем, что успешная загрузка первого файла ещё не доказывает исправность всей цепочки: ошибочный импорт может находиться глубже.
Частые вопросы
Можно ли оставить paths только ради удобства редактора?
Да, когда реальная среда уже поддерживает соответствующее соглашение и paths лишь описывает его компилятору. Документируйте владельца этого соглашения и проверяйте сборку. Если runtime-механизма нет, удобная навигация в редакторе не делает импорт исполнимым.
Почему в TypeScript-файле используется расширение .js?
В данном примере исходники компилируются в JavaScript, а импорты должны указывать на будущие ESM-файлы. NodeNext связывает такие ссылки с исходными TypeScript-файлами при проверке. Прямой запуск TypeScript или другая система сборки может использовать иное соглашение; смешивать режимы без настройки нельзя.