{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"references": [
{ "path": "../shared-utils" },
{ "path": "../ui-components" }
]
}Am lucrat recent la un monorepo cu vreo 12 pachete interne și 3 aplicații web destul de mari. La început, când dădeam un simplu build pe CI, așteptam aproape 3 minute pentru că TypeScript recompila absolut totul de la zero, chiar dacă modificasem doar o amară de virgulă într-un helper de formatat date.
Am rezolvat problema asta trecând totul pe Project References și activând build-urile incrementale. Am redus timpul de build pe CI cu aproape 70%, dar drumul până acolo a fost plin de nervi și configurații dubioase. Dacă folosești doar paths în monorepo, probabil o faci greșit. Hai să-ți explic de ce și cum se face corect.
De ce e "paths" o capcană în monorepo
Când începi un monorepo (cu Yarn sau pnpm Workspaces), tentația e să pui în tsconfig-ul din rădăcină o listă lungă de alias-uri în compilerOptions.paths. Ceva de genul @shared/ui/*: ["packages/ui/*"].
Funcționează la prima vedere. Editorul nu mai dă erori, autocomplete-ul merge perfect. Însă problema apare când vrei să rulezi tsc pe un serviciu individual. TypeScript va compila tot codul importat ca și cum ar face parte nativ din acel serviciu. Nu folosește cache-ul, nu înțelege granițele dintre pachete și, cel mai rău, dacă pachetul tău de UI are erori de tipuri, acestea vor apărea abia când compilezi aplicația mare. Este o abordare extrem de ineficientă pentru că strică izolarea codului.
Salvarea: composite și references
Soluția corectă este să împarți configurația. Fiecare pachet din monorepo trebuie să aibă propriul tsconfig.json cu proprietatea "composite": true activată.
Ce face composite ăsta? Îi spune compilatorului că pachetul respectiv este o unitate independentă, care poate fi construită separat. Când activezi composite, TypeScript te obligă să activezi și declaration (ca să genereze fișiere .d.ts) și incremental. Practic, tsc va genera un fișier de metadata .tsbuildinfo unde ține minte exact ce s-a schimbat de la ultimul build.
Apoi, în aplicația principală care consumă aceste pachete, în loc de paths, folosești proprietatea references la nivelul rădăcinii fișierului de configurare. Compilatorul știe acum ordinea exactă de build. Când rulezi tsc --build (sau tsc -b), TypeScript verifică acele fișiere .tsbuildinfo. Dacă librăria de UI nu s-a modificat, nu o mai recompilează deloc, ci o citește direct din cache.
Trade-off-uri pe care nu ți le spune nimeni
Sună ideal, dar există niște compromisuri destul de enervante de care trebuie să fii conștient înainte să te apuci de refactoring.
În primul rând, ești obligat să rulezi build-ul pe pachetele shared înainte ca aplicația principală să pornească. Dacă scrii cod în @shared/ui și vrei să-l vezi instant în aplicația web, trebuie să rulezi un proces de watch pe tot monorepoul (tsc -b --watch). Altfel, editorul o să se plângă că nu găsește tipurile noi, pentru că el caută fișierele .d.ts generate în folderul dist al pachetului de UI, nu fișierele .ts direct.
În al doilea rând, configurația devine mult mai stufoasă. Ai nevoie de un tsconfig.json de bază de la care extinzi tot, unul în root care doar leagă toate referințele ca să poți da un singur build global, și câte unul specific în fiecare pachet.
Voi cum gestionați monorepo-urile mari? Ați rămas pe alias-uri clasice cu paths sau ați făcut trecerea la Project References?