
Non esistono più scuse per il debito tecnico
Come abbiamo portato su TypeScript un frontend di 13 anni: cast mirati, un ratchet con Oxlint in CI e un agent per il lavoro sporco. Build da 2 min a 15 s.
Quando sono entrato in azienda diversi anni fa e ho visto il codice sul mio Visual Studio Code, come prima reazione ho spalancato gli occhi.
Avevo davanti a me un codice che pagava il prezzo di numerose mani, numerose teste e soprattutto numerosi anni. Uno dei git blame segnava 10 anni, e sono già passati almeno 3 anni da quando sono qui. Era un fossile, altro che legacy.
Se ci aggiungiamo che la parte di cui mi occupavo era quella frontend, quegli anni pesano parecchio: è passata la filosofia Flux con jQuery, i primi esperimenti React con Redux, senza e poi con TypeScript.
Sin da subito il mio obiettivo e la mia visione era di fuggire da questo debito tecnico incolmabile. Si sono susseguite riunioni per tentare di arginare il problema. Abbiamo tentato di strangolare il vecchio codice ma lui era sempre lì, non lo buttavi, tornava sempre.
Gran parte di esso era frutto di considerazioni un po’ lontane dai community standard e questo ha sin da subito rappresentato una grossa spina nel fianco.
Non c’era linting, non c’erano le giuste configurazioni TypeScript per i tipi e non c'era alcun tipo di test - uno dei problemi più grossi se si voleva seguire un refactor manuale componente per componente.
Ci abbiamo messo la bellezza di qualche mese a ripristinare il linting: ereditando una codebase così vecchia, con librerie obsolete o defunte, il primo step è stato di aggiornare o rimuovere tali librerie e rimettere il tanto agognato linting che fino a quel momento era ostaggio di alcune vecchie librerie supportate solo da versioni datate di Node.
Dopo esserci riusciti, bisognava andare alla volta di TypeScript e qui sono iniziati i problemi seri.
La sovraingegnerizzazione, i componenti generici che diventano snodo principale di tutte le varie sezioni della webapp, costituivano un problema molto grosso: aggiustare il tipo significava anche aggiornare la logica del componente e a quel punto ci trovavamo a un bivio: aggiornare la logica, o aggiornare il tipo? In ogni caso i componenti erano tanti e dovevamo fare questi aggiornamenti in modo incrementale per permettere comunque di buildare.
Questo step si è rivelato quasi impossibile con la sola buona volontà dei singoli: sviluppiamo un prodotto e questa attività avrebbe assorbito tempo e budget che forse potevano essere spesi meglio. Così, ci siamo velocemente disillusi: abbiamo semplicemente attivato il typecheck in modo strict solo a livello editor e lasciato così com’era il resto.
Inizialmente contavamo su un file che manualmente aggiornavamo quando aggiustavamo i tipi, poi questa pratica, vuoi perché macchinosa (nonostante ci fosse uno script) vuoi perché non c’è stato il giusto controllo, è andata bellamente a farsi friggere.
Da quel momento di acqua sotto i ponti ne è passata, ma noi continuavamo ad avere praticamente lo stesso debito tecnico di quando sono entrato, con la differenza che mano a mano abbiamo introdotto dei test. Questi si sono rivelati preziosi, specialmente quando nei nostri processi ha fatto il suo ingresso l’AI.
Approfittando di ciò, mi è venuto in mente di dare lustro all’ormai dimenticato e parzialmente smembrato typecheck incrementale, rivedendolo da un altro punto di vista.
Nel tentativo iniziale, l’idea era di correggere tipo o comportamento, ma poi ho cambiato idea: perché farlo subito? Nel frattempo i vecchi componenti generici, vista la ventata di novità che stiamo apportando sui nostri sistemi, stanno mano a mano cadendo in disuso e quindi sarebbe stato meglio fare in modo che almeno il codice nuovo nascesse tipizzato.
Perciò la nuova idea prevedeva dei nuovi passi: se invece di correggere il comportamento, mi limitassi ad utilizzare… dei cast? A questo punto qualcuno potrebbe inorridire, ma ci arrivo.
Per prima cosa ho cercato un codemod adatto. Strumenti come ts-migrate esistono, ma silenziano gli errori con @ts-ignore o @ts-expect-error: spengono l’intera riga, compresi gli errori che arriveranno in futuro. Noi volevamo l’opposto: lasciare invariato il contratto dei componenti, perché cambiarlo avrebbe avuto impatti a cascata, e isolare ogni errore in un cast mirato su una singola espressione, lasciando il typecheck attivo su tutto il resto. Un codemod che per ogni errore di tsc scelga l’espressione e il tipo giusti non esiste: per questo l’ho affidato ad un agent.
Usando i cast continuerei ad avere quei componenti, scritti un po’ così così, con la sola certezza di alcuni test che li usano. Quindi ok, usiamo i cast.
Un cast, va detto, è debito, ma debito esplicito: è visibile, cercabile e misurabile. Un errore di tipo ignorato dalla pipeline e visibile solo sul proprio editor, invece, non lo vede nessuno.
Nella prima iterazione, ho istruito un agent a mettere i cast senza fare nessun tipo di cambiamento al codice: il suo vincolo era di non apportare alcun tipo di modifica se non di tipo.
Con questo cambiamento, il typecheck è stato finalmente introdotto nelle nostre pipeline.
Avevamo un bel po’ di codice pieno di cast, l’idea a questo punto era di imporre come regola il divieto assoluto di cast. A questo punto sembrerebbe un controsenso: perché casti tutto e poi rendi il cast vietato? Mi sono servito di Oxlint, perché permette di escludere i file che si vogliono rendere esenti da questa regola attraverso una singola proprietà.
Il piano dunque era questo: cristallizzare la situazione attuale, sistemare tipi e comportamenti, rimuovere i file dalla lista. Abbiamo scelto un meccanismo di “ratchet”.
Quindi è stato introdotto un job che verifica che il linting sia corretto e che i file sotto quella lista, se modificati e quindi senza più cast, siano effettivamente rimossi. Pena: la pipeline fallisce.
Basterà eseguire un comando da terminale per procedere alla rimozione automatica dei file sistemati dalla lista di Oxlint.
In lista ad oggi ci sono 422 file, ma il numero a questo punto è destinato a decrescere. Il numero sembra spaventoso, ma molti sono file legacy destinati a essere deprecati: è solo una questione di tempo.
L’idea della meccanica del ratchet è la seguente:
// tools/cast-ratchet/generate.mjs (versione semplificata)
const original = readFileSync(CONFIG, 'utf8');
// 1. Tolgo temporaneamente le eccezioni e chiedo a oxlint chi ha ancora cast
writeFileSync(CONFIG, stripOverrides(original));
let report;
try {
const { stdout } = spawnSync(OXLINT, ['-f', 'json'], { encoding: 'utf8' });
report = JSON.parse(stdout);
} finally {
writeFileSync(CONFIG, original); // ripristino sempre
}
const dirty = report.diagnostics
.filter((d) => d.code === 'typescript(consistent-type-assertions)')
.map((d) => d.filename);
// 2. Confronto con la lista committata
const committed = readOverrides(original);
const stale = committed.filter((f) => !dirty.includes(f)); // file ripuliti
const missing = dirty.filter((f) => !committed.includes(f)); // file nuovi con cast
if (process.argv.includes('--check')) {
process.exit(stale.length || missing.length ? 1 : 0);
}
writeFileSync(CONFIG, writeOverrides(original, dirty));// .oxlintrc.json
{
"rules": {
"typescript/consistent-type-assertions": [
"error",
{ "assertionStyle": "never" }
]
},
// ===== BEGIN CAST RATCHET (generated — do not edit manually) =====
"overrides": [
{
"files": [
"src/components/SearchList/index.tsx",
"src/components/GenericForm/utils.ts"
// ...altri N file
],
"rules": { "typescript/consistent-type-assertions": "off" }
}
]
// ===== END CAST RATCHET =====
}// package.json
"scripts": {
"ratchet:casts": "node tools/cast-ratchet/generate.mjs",
"ratchet:casts:check": "node tools/cast-ratchet/generate.mjs --check"
}## .gitlab-ci.yml (o equivalente)
lint:casts:
script:
- yarn ratchet:casts:checkGli agent tuttavia non sono stati impiegati solo in questa occasione.
Nella visione di avanzamento tecnologico a lungo termine, uno degli obiettivi principali era di cambiare bundler, passando da webpack 5 a Vite 8, un passo non di poco conto.
Tuttavia c’erano degli impedimenti.
Il primo: Vite interpreta la sintassi JSX solo nei file con estensione .jsx (o .tsx), mentre i nostri componenti legacy usavano .js. Invece di forzare la configurazione del bundler, ho preferito mettere ordine rinominando i componenti da .js a .jsx.
Per questo approccio avevo inizialmente pensato a un codemod, solo che non copriva questa casistica (nel dettaglio: trovare i file .js controllandone il contenuto, rinominare quei file utilizzando git mv per evitare che i file perdessero la history e infine aggiornare gli import).
Quindi, visto che il codemod non "fittava" perfettamente, ho creato (insieme all'agent) uno script che svolgesse questa procedura - quindi all'agent l'ho fatto eseguire e ho dato istruzione di aprire le MR del caso.
Il secondo: Vite serve il codice sorgente come ESM nativo, e in giro avevamo ancora moduli in CommonJS (require/module.exports). In questo caso il codemod c’era, personalmente ho usato cjstoesm per convertirli tutti.
Come nel caso precedente, ho dato mandato all’agent per l’esecuzione del piano. Quest’ultimo è stato istruito a cambiare la sintassi.
Visto che la review era sia umana che agentica, abbiamo spezzettato in piccole merge request.
Risolvendo questi piccoli intoppi, siamo così riusciti a passare da webpack 5 a Vite 8 e ridurre i job di build da circa 2 minuti a circa… 15 secondi.
Lavorare su una repository così vecchia avrebbe richiesto un lavoro meccanico pressoché impossibile da portare a termine; è una cosa che provavamo a fare da anni. Questo invece è stato il lavoro intenso e corale di 3 persone e un agent: io, con la visione tecnica sul da farsi, 2 reviewer umani e l’AI, che ha fatto praticamente tutto il lavoro sporco e a basso costo.
Davanti a noi ci sono tante migliorie da fare ma sono finalmente fiducioso e consapevole che si possono fare.
In conclusione mi sento di chiedere: se 13 anni di debito tecnico si possono aggredire in poche settimane, quali scuse ci restano per continuare ad accumularne?

