Пошук уроків, статей та іншого контенту
Організуєте TypeScript у monorepo з shared-конфігураціями, project references, пакетами та коректною роздільною здатністю модулів.
Monorepo зберігає кілька пакетів або застосунків в одному репозиторії. Наприклад:
packages/math — бібліотека зі спільною логікою;
packages/config — спільні конфігурації;
apps/demo — застосунок, який використовує бібліотеку.
TypeScript у такій структурі має розв’язати кілька задач:
не дублювати однакові налаштування;
розуміти залежності між пакетами;
збирати пакети в правильному порядку;
генерувати декларації .d.ts;
коректно знаходити пакети через package.json;
не змішувати вихідні файли різних пакетів.
Для цього використовують:
успадкування конфігурацій через extends;
project references через references;
режим збірки tsc -b;
npm workspaces або інший workspace-менеджер;
package.json з exports і types.
Приклад структури:
acme-monorepo/
├── apps/
│ └── demo/
│ ├── src/
│ │ └── index.ts
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ └── math/
│ ├── src/
│ │ └── index.ts
│ ├── package.json
│ └── tsconfig.json
├── configs/
│ └── tsconfig.base.json
├── package.json
└── tsconfig.jsonКожен пакет має власний tsconfig.json. Кореневий конфігураційний файл не компілює код самостійно, а описує зв’язки між проєктами.
Спільні параметри варто винести в окремий файл:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}Файл configs/tsconfig.base.json містить правила, спільні для всіх пакетів.
NodeNextУ цьому прикладі використовується:
{
"module": "NodeNext",
"moduleResolution": "NodeNext"
}Цей режим узгоджує поведінку TypeScript із правилами Node.js для ESM і CommonJS. TypeScript враховує:
поле "type" у package.json;
поле "exports";
поле "types";
умови імпорту пакета;
розширення та формат модулів.
Для сучасного monorepo важливо, щоб TypeScript і Node.js використовували сумісні правила роздільної здатності модулів.
Кореневий tsconfig.json може бути solution-конфігурацією:
{
"files": [],
"references": [
{
"path": "./packages/math"
},
{
"path": "./apps/demo"
}
]
}Поле "files": [] означає, що кореневий проєкт не містить власних файлів для компіляції.
Поле "references" описує проєкти, які потрібно зібрати. TypeScript використовує ці зв’язки, щоб:
визначити порядок компіляції;
перебудовувати лише змінені проєкти;
використовувати декларації залежних проєктів;
перевіряти межі між пакетами.
Проєкт, на який посилаються через references, повинен мати "composite": true.
Конфігурація пакета packages/math/tsconfig.json:
{
"extends": "../../configs/tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": [
"src/**/*.ts"
]
}"composite": true — робить проєкт придатним для project references;
"declaration": true — генерує .d.ts файли;
"declarationMap": true — генерує карти для декларацій;
"rootDir": "./src" — визначає корінь вихідних файлів;
"outDir": "./dist" — визначає каталог результату;
"tsBuildInfoFile" — зберігає інформацію для інкрементальної збірки.
За наявності project references декларації особливо важливі: залежний пакет повинен отримати типізований публічний інтерфейс іншого пакета.
Файл packages/math/package.json:
{
"name": "@acme/math",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"types": "./dist/index.d.ts",
"files": [
"dist"
]
}Вихідний файл packages/math/src/index.ts:
export function sum(left: number, right: number): number {
return left + right;
}Після збірки пакет матиме приблизно таку структуру:
packages/math/
├── dist/
│ ├── index.d.ts
│ ├── index.d.ts.map
│ ├── index.js
│ └── tsconfig.tsbuildinfo
├── package.json
└── src/
└── index.tsПоле "exports" визначає дозволені точки входу в пакет. У цьому випадку споживач може імпортувати корінь пакета:
import { sum } from "@acme/math";Але не повинен імпортувати внутрішні файли на кшталт:
import { sum } from "@acme/math/dist/index.js";Таке обмеження захищає внутрішню структуру пакета та спрощує її подальшу зміну.
Файл apps/demo/tsconfig.json:
{
"extends": "../../configs/tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": [
"src/**/*.ts"
],
"references": [
{
"path": "../../packages/math"
}
]
}Застосунок має посилання на packages/math. Тому TypeScript спочатку збирає бібліотеку, а потім застосунок.
Файл apps/demo/src/index.ts:
import { sum } from "@acme/math";
const result = sum(2, 3);
console.log(result);Файл apps/demo/package.json:
{
"name": "@acme/demo",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@acme/math": "1.0.0"
}
}Властивість "type": "module" потрібна, щоб Node.js інтерпретував згенеровані .js файли як ESM.
Кореневий package.json може описувати всі робочі області:
{
"name": "acme-monorepo",
"private": true,
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"build": "tsc -b",
"clean": "tsc -b --clean"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}Після встановлення залежностей npm створює локальний зв’язок між workspace-пакетами. Для застосунку пакет @acme/math поводиться як звичайна npm-залежність.
Типовий запуск:
npm install
npm run build
node apps/demo/dist/index.jsРезультат:
5Команда tsc -b читає кореневий tsconfig.json, знаходить project references і збирає проєкти в потрібному порядку.
У цьому прикладі порядок буде таким:
packages/math;
apps/demo.
Для очищення результатів збірки використовується:
npm run cleanКоли apps/demo/src/index.ts містить імпорт:
import { sum } from "@acme/math";TypeScript шукає пакет за правилами NodeNext:
знаходить пакет @acme/math у node_modules;
читає його package.json;
аналізує поле "exports";
для типів використовує ./dist/index.d.ts;
для виконання використовує ./dist/index.js.
Project reference також повідомляє TypeScript, що apps/demo залежить від packages/math. Тому під час tsc -b декларації бібліотеки з’являються до перевірки застосунку.
Важливо розрізняти:
references — зв’язки між TypeScript-проєктами;
workspaces — зв’язки між пакетами менеджера пакетів;
exports — публічні точки входу пакета;
paths — альтернативне відображення шляхів під час компіляції.
Для звичайної залежності між пакетами в monorepo бажано, щоб references, workspaces і exports описували одну й ту саму архітектуру.
pathsМожна було б додати до кореневого конфіга:
{
"compilerOptions": {
"paths": {
"@acme/math": [
"./packages/math/src/index.ts"
]
}
}
}Це змінює поведінку TypeScript, але не змінює поведінку Node.js. Node.js не читає paths із tsconfig.json.
У результаті код може успішно пройти перевірку TypeScript, але завершитися помилкою під час запуску:
ERR_MODULE_NOT_FOUNDpaths можуть бути корисними для спеціальних сценаріїв, але вони не є заміною:
npm workspace;
package.json;
exports;
project references.
Якщо код справді є окремим пакетом, краще описати його як пакет.
Пакет повинен експортувати лише ті символи, які є частиною його публічного API:
export function sum(left: number, right: number): number {
return left + right;
}Внутрішні допоміжні функції можна не експортувати:
function validateNumber(value: number): void {
if (!Number.isFinite(value)) {
throw new Error("Значення має бути скінченним числом");
}
}
export function multiply(left: number, right: number): number {
validateNumber(left);
validateNumber(right);
return left * right;
}Згенерований файл index.d.ts міститиме лише експортовані типи та функції. Саме цей файл використовує залежний проєкт під час типізації.
Після першої збірки TypeScript створює файли tsconfig.tsbuildinfo. Вони містять інформацію про попередню компіляцію.
Якщо змінити лише apps/demo/src/index.ts, TypeScript не повинен повністю перебудовувати packages/math. Він використає готові результати залежного проєкту.
Це особливо важливо для monorepo з великою кількістю пакетів. Project references разом із tsc -b допомагають зменшити час локальної та CI-збірки.
Залежності мають бути спрямованими. Наприклад:
apps/demo → packages/mathАле packages/math не повинен залежати від apps/demo.
Якщо пакети утворюють цикл:
packages/a → packages/b → packages/aзбірка та архітектура стають складнішими. Спільну логіку в такому випадку краще винести в окремий нижчий за рівнем пакет.
Project references не усувають циклічні залежності автоматично. Вони лише описують і перевіряють наявний граф проєктів.
compositeЯкщо пакет використовується в references, але не має:
{
"compilerOptions": {
"composite": true
}
}TypeScript не зможе коректно використовувати його як referenced project.
rootDirЯкщо rootDir встановлено на корінь monorepo, файли різних пакетів можуть потрапити до одного каталогу dist.
Кожен пакет повинен мати власні:
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
}
}package.jsonЯкщо застосунок імпортує:
import { sum } from "@acme/math";пакет @acme/math повинен бути в dependencies або devDependencies застосунку. Не варто покладатися лише на те, що пакет фізично доступний у кореневому node_modules.
exports не відповідає результату збіркиЯкщо пакет генерує файл:
dist/index.jsале в exports указано:
{
"import": "./build/index.js"
}Node.js не зможе завантажити пакет.
Шляхи в package.json повинні відповідати фактичній структурі outDir.
Команда:
tsc -p apps/demoперевіряє лише вказаний конфіг і не є повною заміною для solution build.
Для збірки всього графа використовуйте:
tsc -bЯкщо в конфігурації використовується "module": "NodeNext", значення "type" у package.json впливає на формат модулів.
Потрібно послідовно визначити, що пакет використовує:
ESM через "type": "module";
або CommonJS через відповідну конфігурацію та розширення.
Змішування форматів без чітких меж часто спричиняє помилки імпорту під час виконання.
Для організації TypeScript у monorepo:
винесіть спільні параметри в базовий tsconfig;
створіть окремий tsconfig.json для кожного пакета;
увімкніть "composite": true;
налаштуйте rootDir, outDir і генерацію декларацій;
опишіть залежності через references;
використовуйте tsc -b для збірки графа проєктів;
зв’яжіть пакети через workspaces;
визначте публічний API через package.json.exports;
переконайтеся, що exports, types і фактичні файли в dist узгоджені;
використовуйте узгоджений режим роздільної здатності модулів, наприклад NodeNext.