{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true
},
"references": [
{ "path": "../shared-types" },
{ "path": "../logger" }
],
"include": ["src/**/*"]
}Dacă ai încercat vreodată să legi pachete interne într-un monorepo doar cu compilerOptions.paths, știi deja coșmarul: IDE-ul e fericit, dar CI-ul recompilează tot repo-ul la cel mai mic spațiu modificat. La un monorepo cu 14 pachete pe care l-am migrat anul trecut, timpul de typecheck a scăzut de la 4m 12s la doar 48s pur și simplu trecând pe Project References și incremental build corect.
Problema principală e că paths este doar un alias pentru rezolvarea modulelor. Îi zice editorului unde să caute tipurile, dar nu-i explică arborele de dependențe lui tsc. Fără referințe explicite, TypeScript tratează tot codul ca un bloc gigantic monolit.
De ce paths singur e o bombă cu ceas
Când folosești exclusiv paths, TypeScript analizează fișierele la fiecare run cap-coadă. Dacă package-ul ui importă din core, compilatorul re-evaluează core de la zero în contextul lui ui. Faci o modificare de un caracter într-un DTO și toate cele 14 pachete sunt reverificate.
Mai mult, apare des problema compilărilor circulare subtile pe care bundlerul le maschează, dar care în CI produc erori criptice de tip Type instantiation is excessively deep. Pentru a rezolva treaba asta curat, ai nevoie de două concepte native din TypeScript: composite: true și references.
Trecerea la composite: true
Când adaugi "composite": true în compilerOptions, TypeScript impune câteva reguli stricte, dar extrem de benefice:
- Ești obligat să activezi
declaration: true(trebuie să emiți fișiere.d.ts). - Fiecare fișier din proiect trebuie să fie cuprins explicit în
includesaufiles. - TypeScript generează un fișier
.tsbuildinfocare conține graful dependențelor și hash-urile fișierelor.
Odată ce pachetul de bază e compilat, pachetele dependente nu mai parsează sursele TS din el, ci citesc doar declarațiile .d.ts gata generate. Dacă ai 80k linii de cod în backend-core, pachetul de API va citi doar semnăturile, economisind memorie și CPU masiv.
La rădăcină, rulezi tsc --build (sau tsc -b). Flag-ul -b știe să citească câmpul references și compilează pachetele strict în ordinea topologică corectă, sărind peste cele care nu s-au schimbat de la ultimul hash din .tsbuildinfo.
Trade-off-uri reale și bătăi de cap
Nu e totul lapte și miere, altfel toată lumea folosea asta din prima zi. Sunt câteva compromisuri pe care trebuie să le accepți:
- Nu mai poți folosi
noEmit: true: Proiectele composite trebuie să genereze output fizic pe disc (fișierele.d.tsși fișierul.tsbuildinfo). Dacă vrei typecheck în CI, trebuie să lașitscsă scrie fișiere sau să le direcționezi într-un folder de cache. - Navigarea prin cod în IDE: Fără
"declarationMap": true, dacă dai "Go to Definition" dintr-un pachet în altul, VS Code te va arunca îndist/index.d.tsîn loc de fișierul.tsoriginal. Ține neapăratdeclarationMapactiv. - Cleanup la fișiere șterse: Dacă redenumești un fișier
.ts, vechiul.d.tsrămâne îndistșitscnu-l șterge automat, ceea ce duce la fantome de tipuri până când dai unrm -rf distmanual.
Voi cum gestionați dependențele între pachete: ați configurat direct project references în tsc sau ați lăsat greul pe Turborepo / Nx și ignorați ce face compilatorul pe dedesubt?