Project references: сборка связанных TypeScript-проектов через tsc -b
Автор: Казачкин Даниил Михайлович · Обновлено
Project references описывает зависимости между отдельными TypeScript-проектами. Компилятор получает граф, по которому может собирать зависимости раньше потребителей и повторно…
Project references описывает зависимости между отдельными TypeScript-проектами. Компилятор получает граф, по которому может собирать зависимости раньше потребителей и повторно использовать результаты. Разберём библиотеку расчётов и маленькое приложение, чтобы увидеть роль references, composite, деклараций и режима tsc -b на запускаемом результате.
Цель и предварительные знания
После урока вы сможете собрать два связанных проекта одной командой и объяснить, какие файлы нужны для проверки, а какие — для запуска. Нужны знания tsconfig и относительных ESM-импортов. Используем Node.js 24 и TypeScript 6. Здесь нет публикации пакетов и менеджера монорепозиториев: сначала полезно увидеть механизм компилятора в чистом виде.
Явный граф из двух узлов
Создайте новую папку и корневой package.json. Он задаёт ESM-режим для всех показанных файлов; вложенные package.json в упражнении не нужны.
{
"private": true,
"type": "module"
}pnpm add -D typescript@6 @types/node@24Создайте корневой tsconfig.json. Он служит точкой входа сборки, а собственных исходников не содержит. Пустой files нужен, чтобы случайно не собирать все вложенные файлы ещё раз как один общий проект.
{
"files": [],
"references": [
{ "path": "./domain" },
{ "path": "./app" }
]
}Папка domain содержит расчёты. Сохраните domain/tsconfig.json: composite делает проект пригодным для ссылок со стороны других проектов. Мы явно задаём declaration, чтобы назначение выходных .d.ts было видно при чтении.
{
"compilerOptions": {
"composite": true,
"declaration": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": [],
"strict": true,
"noEmitOnError": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}В domain/src/index.ts разместите полностью независимую функцию. В этом проекте не нужны глобальные объявления Node.js: вычисление использует только стандартные значения языка.
export type Booking = {
places: number;
pricePerPlace: number;
};
export function calculateTotal(booking: Booking): number {
return booking.places * booking.pricePerPlace;
}Теперь app/tsconfig.json. Ссылка на domain описывает зависимость сборки; список references в корне сам по себе не объявляет зависимости между двумя перечисленными узлами.
{
"compilerOptions": {
"composite": true,
"declaration": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"noEmitOnError": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../domain" }]
}Приложение в app/src/main.ts использует публичный выход библиотеки. Для учебного размещения относительный путь одинаково верен из src и dist. В публикуемых пакетах такой доступ обычно оформляют через имя зависимости и её exports.
import { calculateTotal } from '../../domain/dist/index.js';
console.log(calculateTotal({ places: 3, pricePerPlace: 240 }));pnpm exec tsc -b --verbose --stopBuildOnErrors
node app/dist/main.jsПриложение печатает 720. В подробном журнале сборки domain обрабатывается раньше app. Появляются JavaScript-файлы, декларации и служебная информация о сборке. Повторите первую команду без изменений: компилятор сможет определить, какие результаты уже актуальны. Формулировки журнала зависят от версии, но повторный запуск программы должен дать тот же результат.
Почему обычного tsc -p недостаточно
tsc -p app проверяет выбранный проект, но не является командой построения всей цепочки ссылок. На чистом диске необходимые выходы domain ещё отсутствуют. Режим -b, сокращение от build, занимается графом: выясняет порядок и актуальность проектов. Можно указать корневую конфигурацию или нужную точку входа с её зависимостями.
References не создаёт JavaScript-импорты и не устанавливает пакеты. Тип Booking исчезнет при компиляции, а calculateTotal должен существовать в реальном domain/dist/index.js. Если доставить на сервер только app/dist, декларации не спасут запуск. Для этого примера необходимо сохранить выходы обоих проектов и их относительное размещение.
Что даёт composite
Состав такого проекта должен быть определён достаточно явно: файлы реализации должны попадать в include или files. Публичные типы становятся доступными через декларации. Это позволяет потребителю работать с границей проекта, а не считать весь репозиторий одним неограниченным набором исходников.
Служебные .tsbuildinfo описывают состояние предыдущей сборки; это не исходный код и не библиотека для runtime. При организации кэша храните их согласованно с выходными файлами и версиями инструментов. Само присутствие файла кэша не гарантирует ускорение: сравнивайте время чистой и повторной сборки на реальном графе.
Ошибки и остановка зависимых сборок
Современный build-режим может продолжать работу по графу при ошибках. В упражнении --stopBuildOnErrors явно останавливает сборку зависимых проектов после ошибки предшественника, а noEmitOnError запрещает новый выход ошибочного проекта. Это разные решения: одно управляет движением по графу, другое — генерацией файлов.
Остатки предыдущего dist после неудачной сборки не становятся проверенным результатом текущего исходника. В CI публикация должна зависеть от успешного кода завершения всей команды. Не запускайте старое приложение и не принимайте его успешный вывод за доказательство того, что новая версия собралась.
Типичные ошибки
Самая заметная ошибка — добавить references, но оставить все исходники включёнными и в корневой проект, и во вложенные. Другая — импортировать приватный файл соседнего src вместо согласованной точки входа. Граница тогда существует в конфигурации, но нарушается в коде; сообщения о rootDir и составе проекта сигнализируют о проблеме устройства, а не о необходимости выключить проверку.
Не вводите циклические зависимости ради симметрии каталогов. Если domain требует app, а app требует domain, вероятно, общий контракт нужно вынести ниже или передавать поведение параметром. Количество папок само по себе не улучшает архитектуру: маленькому приложению может быть достаточно одного tsconfig.
Практика с разбором
Переименуйте в Booking поле places в participants и измените calculateTotal. Не исправляя приложение, запустите общую сборку. Предскажите, где появится ошибка, какие выходы могут сохраниться от прошлого запуска и почему запуск старого main.js ничего не говорит о новом контракте.
Разбор: domain описывает новый публичный тип, а вызов в app больше ему не соответствует. Замените places на participants в app и повторите сборку; вывод снова будет 720. Затем измените только тело расчёта, например добавьте фиксированный сбор, и наблюдайте журнал. Не делайте вывод, что любое изменение обязано полностью пересобирать всех потребителей: компилятор учитывает доступную информацию об актуальности.
Частые вопросы
Нужны ли project references каждому монорепозиторию?
Нет. Они полезны, когда есть самостоятельные границы проверки и понятный граф зависимостей. Сначала определите, какие проекты имеют отдельный публичный контракт и выход. Если папки разделены только визуально, дополнительная конфигурация может усложнить работу без заметной пользы.
Можно ли удалить tsbuildinfo при проблеме сборки?
Этот служебный файл можно восстановить повторной сборкой, однако удаление кэша не исправляет неверный импорт или цикл. Для диагностики сначала прочитайте ошибку и подробный журнал. Чистая проверка полезна как воспроизведение, но не должна подменять исправление причины.