
Build più veloci: migrare al compilatore nativo di TypeScript 7 e a Rolldown
Strategie di migrazione a TypeScript 7 e Rolldown in un monorepo: breaking change, --checkers e --builders, Vite 8, tsdown e benchmark affidabili in CI/CD.
Per dieci anni la toolchain JavaScript è stata scritta, in gran parte, in JavaScript. Funzionava, ma su un monorepo con qualche centinaio di migliaia di righe il type-check in CI durava minuti e la build di produzione altrettanti. Nel 2026 i due pezzi più lenti della pipeline sono stati sostituiti da motori nativi: il compilatore di TypeScript 7, portato in Go, e Rolldown, il bundler in Rust che da Vite 8 è il default. Entrambi promettono build da 8 a 10 volte più rapide, e in certi casi anche di più.
Le promesse, però, si misurano. In questo articolo vediamo come migrare un monorepo in modo incrementale: come gestire i breaking change di default, come configurare il parallelismo con --checkers e --builders senza saturare i runner, e come costruire un benchmark in CI che dica davvero quanto hai guadagnato in tempo e in memoria.
Prima di tutto: separare type-check e build
Il guadagno più grande non arriva da uno strumento, ma da una decisione architetturale che molti progetti hanno già preso a metà: chi controlla i tipi non è chi produce il JavaScript.
| Compito | Strumento nel 2026 | Cosa fa |
|---|---|---|
| Type-check | tsc (TypeScript 7, Go) |
Controlla i tipi, --noEmit |
| Transpilazione e bundling app | Vite 8 con Rolldown + Oxc (Rust) | Rimuove i tipi file per file, bundle, minificazione |
Build di librerie + .d.ts |
tsdown (Rolldown) o tsc --build |
JS per il pacchetto e dichiarazioni di tipo |
Il bundler non legge i tipi: Oxc li rimuove file per file, senza conoscere il resto del programma. Per questo i due passaggi possono girare in parallelo come job separati, e un errore di tipo non deve bloccare il feedback sulla build (e viceversa). Se il tuo script di build oggi è tsc && vite build, questo è il primo cambiamento da fare, prima ancora di aggiornare le versioni.
// package.json di un'app
{
"scripts": {
"typecheck": "tsc --noEmit --checkers 4",
"build": "vite build"
}
}
Passo 0: misura il punto di partenza
Senza una baseline non saprai mai se la migrazione è servita, né a cosa attribuire una regressione. Misura tre cose, separatamente, sulla stessa macchina:
- tempo di type-check a freddo;
- tempo di build a freddo (senza cache di Turborepo/Nx, senza
node_modules/.vite); - picco di memoria di ciascun processo.
hyperfine è lo strumento giusto per i tempi, perché ripete il comando e riporta media e deviazione standard:
hyperfine --warmup 1 --runs 5 \
--prepare 'rm -rf dist node_modules/.vite' \
'npx vite build'
Per la memoria, /usr/bin/time -v (GNU time, su Linux) riporta il Maximum resident set size:
/usr/bin/time -v npx tsc --noEmit 2>&1 | grep "Maximum resident"
ℹ️ Misura a freddo e a caldo
In un monorepo con cache remota (Turborepo, Nx) la maggior parte delle build in CI è “a caldo”: i pacchetti non toccati vengono ripristinati dalla cache. Il guadagno dei tool nativi pesa soprattutto sui cache miss — una PR che tocca un pacchetto alla base del grafo, o il primo build su un branch nuovo. Misura entrambi i casi, o rischi di sottovalutare (o sopravvalutare) il risultato.
Parte 1: TypeScript 7
TypeScript 7 è un porting, non una riscrittura: stesso checker, stessa semantica dei tipi di 6.0. Quello che cambia sono i default di tsconfig.json, le opzioni rimosse e il parallelismo. I dettagli sono nell’approfondimento su TypeScript 7; qui ci concentriamo su come applicarli a un monorepo senza fermare il team.
Strategia: prima 6.0, poi 7.0
TypeScript 6.0 esiste proprio come ponte: segnala come deprecazioni tutto quello che in 7.0 diventa un errore. La sequenza più sicura è:
- aggiornare tutto il monorepo a 6.0 e azzerare i warning di deprecazione;
- attivare in 6.0 le opzioni che in 7.0 diventano default (
stableTypeOrdering,typesesplicito,rootDiresplicito); - solo a quel punto passare a 7.0, che a quel punto dovrebbe richiedere poche modifiche.
I breaking change di default, in un monorepo
Nel singolo progetto i nuovi default si sistemano in dieci minuti. In un monorepo il problema è che ogni pacchetto ha il suo tsconfig.json, spesso ereditato da una base comune. Conviene fissare tutto nella base, in modo esplicito, così la migrazione diventa una modifica in un file solo:
// tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"target": "es2024",
"module": "esnext",
"moduleResolution": "bundler",
"types": [],
"noUncheckedSideEffectImports": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}
// packages/api/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"]
},
"include": ["src"]
}
I tre punti che rompono più spesso:
types: []: i pacchetti Node perdonoprocesseBuffer, quelli di test perdonodescribeeexpect. Ogni pacchetto dichiara i suoi globali.rootDir: dove non è esplicito, la struttura didist/può cambiare (dist/src/index.jsinvece didist/index.js) e rompere gliexportsdipackage.json. Va controllato pacchetto per pacchetto.baseUrlrimosso: gli alias vanno riscritti inpathsrelativi altsconfig.json("@/*": ["./src/*"]).
isolatedModules e verbatimModuleSyntax non sono richiesti da TypeScript 7, ma sono la garanzia che ogni file possa essere transpilato da solo — cioè che Oxc e Rolldown producano lo stesso risultato di tsc. Se separi type-check e build, attivali.
Il ponte per gli strumenti: TypeScript 6 in parallelo
TypeScript 7.0 non ha ancora un’API programmatica stabile: typescript-eslint, Volar (Vue), i plugin di Svelte, Astro e MDX continuano a importare typescript e hanno bisogno di 6.0 fino a 7.1. La soluzione è tenere entrambi i compilatori, con un alias npm per il pacchetto 6.0 (@typescript/typescript6) come descritto nell’approfondimento: il linter usa 6.0, il type-check in CI usa il binario nativo.
Avere entrambi installati ha un vantaggio collaterale: puoi confrontare i risultati. Prima di spegnere 6.0 in CI, per qualche giorno lancia entrambi e confronta le diagnostiche:
npx tsc6 -p packages/api --noEmit --pretty false | sort > ts6.txt
npx tsc -p packages/api --noEmit --pretty false --checkers 4 | sort > ts7.txt
diff ts6.txt ts7.txt
L’ordinamento serve perché l’ordine delle diagnostiche può cambiare con il parallelismo. Le differenze attese sono quelle dovute ai nuovi default e all’ordine stabile dei tipi nei messaggi (string | number contro number | string); qualunque altra differenza va capita prima di fidarsi del nuovo compilatore.
--checkers e --builders senza saturare la CPU
TypeScript 7 introduce due livelli di parallelismo:
--checkers N(default 4): quanti type checker lavorano in parallelo sui file dello stesso progetto.--builders N: quanti progetti referenziati vengono compilati contemporaneamente contsc --build.
Il punto che i benchmark sintetici non mostrano è che in un monorepo questi due numeri si moltiplicano, e si moltiplicano anche con il parallelismo dell’orchestratore:
thread di type-check ≈ task paralleli di Turborepo/Nx × builders × checkers
Turborepo, per default, esegue fino a 10 task in parallelo. Dieci pacchetti che lanciano tsc --checkers 4 sono 40 checker su un runner GitHub Actions standard con 4 vCPU e 16 GB di RAM: il risultato non è più veloce, è più lento e a rischio di OOM, perché ogni checker costruisce i propri tipi e la memoria cresce con il numero di checker.
Due strategie funzionano:
A. Un solo tsc --build dalla radice, lasciando a TypeScript la gestione del grafo:
// tsconfig.json alla radice del monorepo
{
"files": [],
"references": [
{ "path": "packages/core" },
{ "path": "packages/ui" },
{ "path": "packages/api" },
{ "path": "apps/web" }
]
}
npx tsc --build --builders 2 --checkers 2
Con i project references i pacchetti devono avere composite: true ed emettere dichiarazioni (emitDeclarationOnly), ma ottieni l’incrementalità di tsc --build: i progetti non modificati non vengono ricontrollati.
B. L’orchestratore gestisce il parallelismo, e ogni tsc ne usa poco:
turbo run typecheck --concurrency=2
# con "typecheck": "tsc --noEmit --checkers 2" in ogni pacchetto
Questa strategia si sposa meglio con la cache remota di Turborepo/Nx, perché ogni pacchetto è un task cacheabile separato.
In entrambi i casi la regola è la stessa: il prodotto dei livelli di parallelismo non deve superare di molto i core disponibili. E fissa i valori negli script, non lasciarli ai default: oltre alle prestazioni, un numero diverso di checker può, in rari casi, produrre diagnostiche diverse, quindi locale e CI devono usare gli stessi valori.
💡 GOMAXPROCS
Il compilatore nativo è un programma Go, quindi rispetta la variabile d’ambiente GOMAXPROCS,
che limita il numero di thread del sistema operativo che eseguono codice Go in contemporanea. Le
versioni recenti del runtime Go leggono già i limiti di CPU dei container (cgroup), ma su runner
condivisi o con limiti configurati in modo insolito impostarla esplicitamente è un modo semplice
per mettere un tetto all’uso di CPU di tutti i processi tsc di un job.
Dichiarazioni .d.ts senza type checker: isolatedDeclarations
Nei monorepo con molte librerie interne, generare i .d.ts è spesso la parte più lenta della build, perché richiede il type checker. Con isolatedDeclarations: true, TypeScript ti obbliga ad annotare esplicitamente i tipi esportati (valori di ritorno delle funzioni pubbliche, costanti esportate):
// ❌ con isolatedDeclarations: il tipo di ritorno va dedotto dal corpo
export function createClient(url: string) {
return new ApiClient(url);
}
// ✅ il tipo è dichiarato, il .d.ts si genera leggendo solo questo file
export function createClient(url: string): ApiClient {
return new ApiClient(url);
}
In cambio, le dichiarazioni si possono generare file per file, senza type checker, con Oxc. È quello che fa tsdown, il bundler per librerie basato su Rolldown (l’erede naturale di tsup):
// packages/core/tsdown.config.ts
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true, // con isolatedDeclarations attivo usa Oxc invece del compilatore
});
Il type-check resta compito di tsc --noEmit; la build della libreria diventa indipendente dal checker e molto più rapida.
Parte 2: Rolldown con Vite 8
Vite 8, stabile da marzo 2026, sostituisce la coppia esbuild (dev) + Rollup (build) con un unico bundler, Rolldown, arrivato alla 1.0 stabile a maggio 2026. Le trasformazioni JS/TS e la minificazione passano a Oxc, il CSS si minifica con Lightning CSS. Il vantaggio non è solo la velocità: dev e build usano finalmente lo stesso motore, quindi sparisce un’intera classe di bug “funziona in dev, si rompe in build”.
Migrazione in due passi
Per tutto ciò che va oltre un progetto banale, conviene separare il cambio di bundler dal cambio di versione di Vite:
1. Su Vite 7, prova rolldown-vite (il pacchetto che ha anticipato l’integrazione) come drop-in:
{
"devDependencies": {
"vite": "npm:[email protected]"
}
}
Se qualcosa si rompe qui, sai che dipende da Rolldown e non da altre novità di Vite 8.
2. Passa a Vite 8 sostituendo l’alias con "vite": "^8.0.0" e applicando le rinomine della configurazione.
Le modifiche di configurazione principali
Vite 8 include un livello di compatibilità che converte automaticamente buona parte della vecchia configurazione, ma conviene migrare esplicitamente per non dipendere da opzioni deprecate:
| Vite 7 | Vite 8 |
|---|---|
build.rollupOptions |
build.rolldownOptions |
worker.rollupOptions |
worker.rolldownOptions |
optimizeDeps.esbuildOptions |
optimizeDeps.rolldownOptions |
esbuild (transform JS/TS) |
oxc |
esbuild.jsx: "automatic" |
oxc.jsx: { runtime: "automatic" } |
output.manualChunks (oggetto) |
rimosso → output.codeSplitting |
output.manualChunks (funzione) |
deprecato → output.codeSplitting |
| minificazione JS con esbuild | Oxc (default) |
| minificazione CSS con esbuild | Lightning CSS (default) |
transformWithEsbuild (plugin) |
transformWithOxc |
Il caso più comune in un’app reale è lo split dei vendor. Prima:
// vite.config.ts — Vite 7
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
react: ["react", "react-dom"],
charts: ["recharts"],
},
},
},
},
});
Dopo, con i gruppi di codeSplitting, che supportano priorità e soglie di dimensione:
// vite.config.ts — Vite 8
export default defineConfig({
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{ name: "react", test: /node_modules[\\/]react(-dom)?[\\/]/, priority: 20 },
{ name: "charts", test: /node_modules[\\/]recharts/, priority: 15 },
{ name: "vendor", test: /node_modules/, priority: 10 },
],
},
},
},
},
});
Cosa può rompersi
- Interop CommonJS: l’import
defaultdai moduli CJS ora è gestito in modo coerente, ma diverso da prima. Se unimport x from "pacchetto-cjs"restituisce l’oggetto sbagliato,legacy.inconsistentCjsInterop: trueripristina temporaneamente il vecchio comportamento mentre sistemi il codice. - Plugin che usano internals: i plugin che si appoggiano a hook Rollup non supportati (
resolveImportMeta,renderDynamicImport,resolveFileUrl,shouldTransformCachedModule) o che chiamano esbuild direttamente vanno verificati uno per uno. esbuild non è più una dipendenza di Vite: se un plugin ne ha bisogno, va installato come devDependency. - Decoratori: Oxc non abbassa i decoratori nativi. Se li usi (o usi decoratori legacy con
experimentalDecorators), serve@rolldown/plugin-babelo@rollup/plugin-swc. - Browser target: il default sale a Chrome/Edge 111, Firefox 114, Safari 16.4. Se supporti browser più vecchi, imposta
build.targetesplicitamente. - Formati di output: Rolldown non supporta
systemeamd. - Opzioni esbuild senza equivalente:
esbuild.banner/footervanno reimplementati con un piccolo plugin,manglePropse simili non sono supportati.
⚠ Verifica il bundle, non solo la build
Una build che termina senza errori non significa un bundle equivalente. Dopo la migrazione,
confronta numero e dimensione dei chunk (per esempio con rollup-plugin-visualizer, compatibile
con Rolldown), lancia i test end-to-end sul build di produzione e tieni d’occhio i
Core Web Vitals: un chunk di vendor diviso in modo
diverso può cambiare l’LCP più di quanto la build più veloce faccia risparmiare in CI.
Parte 3: un benchmark che regge in CI
I benchmark dei release post sono fatti su codebase molto grandi e su macchine potenti. Quello che conta è il tuo monorepo sul tuo runner. Un workflow minimale per GitHub Actions, da lanciare manualmente sul branch di migrazione:
# .github/workflows/build-benchmark.yml
name: build-benchmark
on: workflow_dispatch
jobs:
bench:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: sudo apt-get update && sudo apt-get install -y hyperfine
- name: Type-check (TS 6 vs TS 7)
run: |
hyperfine --warmup 1 --runs 5 --export-markdown typecheck.md \
'npx tsc6 -b --force' \
'npx tsc -b --force --builders 2 --checkers 2'
- name: Peak memory
run: |
/usr/bin/time -v npx tsc6 -b --force 2>&1 | grep "Maximum resident" | tee -a memory.txt
/usr/bin/time -v npx tsc -b --force --builders 2 --checkers 2 2>&1 | grep "Maximum resident" | tee -a memory.txt
- name: Build app (cold)
run: |
hyperfine --runs 3 --prepare 'rm -rf apps/web/dist apps/web/node_modules/.vite' \
--export-markdown build.md 'npx turbo run build --filter=web --force'
- run: cat typecheck.md build.md memory.txt >> "$GITHUB_STEP_SUMMARY"
Qualche accorgimento per numeri affidabili:
- Stesso runner, stesso job. I runner condivisi hanno varianza alta: confrontare un job di ieri con uno di oggi non dice molto. Esegui baseline e nuova versione nello stesso job, uno dopo l’altro.
--forcesutsc -be su Turborepo/Nx, per escludere cache incrementali e remote.- Ripeti. Cinque esecuzioni con hyperfine e la deviazione standard ti dicono se una differenza del 10% è reale o rumore.
- Memoria, non solo tempo. Alzare
--checkersriduce il tempo e aumenta la RAM. Su runner piccoli il punto ottimo è spesso più basso del default. - Misura il tempo totale della pipeline, non solo dei singoli comandi: se type-check e build ora girano in parallelo, il guadagno reale è il job più lungo, non la somma.
- Prova più valori di parallelismo sulla tua macchina tipica (per esempio
--checkers 1, 2, 4, 8) prima di sceglierne uno: la curva si appiattisce presto, e oltre un certo punto i checker in più costano solo memoria.
Un’ultima nota sui numeri: se tsc prima durava 40 secondi e ora 5, ma l’intera pipeline passa da 9 a 7 minuti, il collo di bottiglia era altrove (installazione delle dipendenze, test, upload degli artefatti). È comunque un buon risultato, ma è il dato da comunicare al team.
Checklist di migrazione
- Separa type-check (
tsc --noEmit) e build (bundler) in script e job distinti. - Misura la baseline: tempi a freddo e a caldo, picco di memoria.
- Aggiorna a TypeScript 6.0 e azzera le deprecazioni.
- Rendi espliciti in
tsconfig.base.jsontypes,rootDir,module,moduleResolution; rimuovibaseUrl. - Installa TypeScript 7 mantenendo
@typescript/typescript6per linter e plugin; confronta le diagnostiche dei due compilatori. - Scegli una strategia di parallelismo (un solo
tsc --buildoppure orchestratore) e fissa--checkers/--buildersnegli script. - Valuta
isolatedDeclarationse tsdown per le librerie interne. - Prova
rolldown-vitesu Vite 7, poi passa a Vite 8 e rinomina le opzioni (rolldownOptions,oxc,codeSplitting). - Verifica plugin, interop CJS, decoratori e browser target.
- Confronta i bundle e lancia i test end-to-end sul build di produzione.
- Ripeti il benchmark nello stesso job CI e documenta tempo, memoria e durata totale della pipeline.
FAQ
❓ Devo migrare TypeScript e Vite insieme?
No, ed è meglio di no. Sono due migrazioni indipendenti: TypeScript 7 cambia il type-check, Rolldown cambia il bundling. Farle in PR separate rende ogni regressione attribuibile a una sola causa.
❓ Quanti checker devo usare in CI?
Dipende dal runner e da quanti task giri in parallelo. Su un runner da 4 vCPU con un solo tsc --build, il default di 4 è ragionevole; se l’orchestratore esegue già più task insieme, scendi a
1–2 checker per processo. Misura tempo e memoria con due o tre valori e fissa quello migliore.
❓ Posso usare TypeScript 7 se uso typescript-eslint o Vue?
Sì per il type-check in CI, no (ancora) per gli strumenti che usano l’API del compilatore: fino a
TypeScript 7.1 linter, Volar e i plugin dei framework devono usare 6.0 tramite il pacchetto
@typescript/typescript6.
❓ Vite 8 fa anche il type-check?
No, come le versioni precedenti: Oxc rimuove i tipi senza controllarli. Il type-check va eseguito
con tsc --noEmit in uno script o job separato (o con vite-plugin-checker se lo vuoi durante lo
sviluppo).
❓ Rolldown funziona senza Vite?
Sì. Rolldown ha una propria CLI e un’API compatibile con quella di Rollup, ed è la base di tsdown per le librerie. Per le applicazioni, però, il modo più semplice di adottarlo resta Vite 8.
Conclusioni
La migrazione ai tool nativi è una delle poche ottimizzazioni che non chiedono di riscrivere codice applicativo: il linguaggio resta lo stesso, il bundle dovrebbe restare equivalente, cambiano i motori. Il lavoro vero sta nei bordi — default di configurazione, plugin, parallelismo che si moltiplica tra livelli — ed è lì che una migrazione incrementale e un benchmark onesto fanno la differenza tra “abbiamo aggiornato le dipendenze” e “la CI ora impiega metà del tempo”.
Se vuoi partire dalle novità del compilatore, leggi l’introduzione a TypeScript 7; per i dettagli su ogni opzione rimossa, l’approfondimento.
Riferimenti

Co-Fondatore & CTO presso PAPION. Senior full-stack engineer specializzato in React, TypeScript, Node.js e sicurezza applicativa.