{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true
},
"include": ["src/**/*"],
"references": [
{ "path": "../core-utils" }
]
}Am dat cu capul de pragul de sus acum un an, la un proiect cu vreo 14 pachete interne gestionate cu pnpm. Aveam în total cam 90k linii de cod TypeScript. La început, totul părea simplu: am trântit niște path-uri în tsconfig.json-ul din rădăcină și am zis că gata, avem monorepo.
Marea țeapă? De fiecare dată când modificam o virgulă într-un pachet de utilitare, tsc recompila absolut tot. Un build dura aproape 3 minute pe un CI destul de potent. Atunci am decis să facem trecerea la Project References și modul composite. Am salvat cam 65% din timpul de build pe CI datorită cache-ului incremental.
Iată ce am învățat din toată durerea asta și cum arată o structură care chiar funcționează.
De ce paths simplu e o capcană în monorepo
Când folosești doar "paths": { "@shared/*": ["packages/shared/src/*"] }, TypeScript doar "traduce" importurile în timpul analizării statice. El nu știe că acele pachete sunt entități independente care ar trebui compilate separat.
Dacă rulezi tsc --watch în aplicația principală, compilerul va procesa direct fișierele sursă din @shared. Asta înseamnă că recompilează codul de fiecare dată. În plus, dacă ai dependințe circulare între pachete, paths te lasă să le scrii bine mersi, iar la runtime te trezești cu crash-uri obscure de tip undefined din cauza ordinii de încărcare.
Magia din composite și Project References
Soluția matură este să tratăm fiecare pachet ca pe un proiect TypeScript de sine stătător. Pentru asta avem nevoie de trei flag-uri esențiale în tsconfig.json-ul pachetului partajat (cel importat):
"composite": true– Îi spune lui TS că acest proiect poate fi referențiat de altele. Activează automat generarea de fișiere.d.tsși metadata pentru build-uri incrementale."declaration": true– Generează fișierele de definiții. Fără ele, proiectele care importă pachetul nu ar ști ce tipuri să folosească la analiză."incremental": true– Permite salvarea unui fișier cache (tsconfig.tsbuildinfo) pentru a compila ulterior doar ce s-a modificat efectiv.
În pachetul consumator (de exemplu, aplicația ta de Next.js sau NestJS), în loc de path-uri chioare, adaugi o referință directă către folderul pachetului partajat în array-ul references de la finalul fișierului.
Trade-off-ul sincer: de ce nu o face toată lumea?
Să fim sinceri, setup-ul ăsta vine cu un cost destul de piperat de configurare și mentenanță.
În primul rând, ai mult boilerplate. Trebuie să ai un tsconfig.json în rădăcină (care doar leagă proiectele prin referințe), un tsconfig.base.json pentru setări comune și câte un tsconfig.json specific în fiecare pachet. Dacă adaugi un pachet nou în monorepo, trebuie să actualizezi manual referințele în alte 2-3 locuri.
În al doilea rând, IDE-urile (în special VS Code) mai iau razna. Uneori modifici ceva în pachetul A, iar pachetul B nu vede schimbarea de tip instantaneu. Te trezești destul de des dând un TypeScript: Restart TS Server din paleta de comenzi.
Concluzia mea
Dacă ai un monorepo mic, cu 2-3 pachete și sub 20k linii de cod, rămâi pe path aliases simple în bundler (Webpack/Vite/Esbuild). Nu merită bătaia de cap cu composite. Dar dacă proiectul crește și build-ul pe CI devine un moment bun de mers la cafea, Project References e singura cale corectă. Economisești timp, procesor și bani pe CI.
Voi cum gestionați asta? Ați trecut la Project References sau folosiți build tools externe gen Turbo ori Nx care se ocupă de cache-ul ăsta la nivel mai înalt?