La discussione «TypeScript o JavaScript» viene di solito condotta come una questione di preferenza. È più utile porla in termini di quale classe di errori si vuole eliminare, e a quale costo — perché i tipi ne eliminano una molto precisa, e ne lasciano passare un'altra che molti credono coperta.

La classe di errori che i tipi trovano#

carrello.jsJavaScript
// Il bug che i tipi trovano e i test spesso no, perché il codice// "funziona": non solleva eccezioni, produce solo il risultato sbagliato.function totale(righe) {  return righe.reduce((s, r) => s + r.prezzo * r.quantita, 0);}// Il modulo HTML restituisce stringhe. Nessuno se ne accorge.totale([{ prezzo: '10', quantita: 2 }]);     // → '010'  (!)//        └─ 0 + '10' → '010', poi '010' * 2 ... e il carrello sbaglia// Con i tipi non compila nemmeno:type Riga = { prezzo: number; quantita: number };function totale2(righe: Riga[]): number { ... }totale2([{ prezzo: '10', quantita: 2 }]);//                ~~~~~~ Type 'string' is not assignable to type 'number'

Il punto interessante non è che l'errore esista: è che non produce alcun sintomo. Nessuna eccezione, nessuno stack trace, nessun log. Il carrello mostra un totale sbagliato e continua a funzionare. Un test che verificasse «la funzione restituisce un valore» passerebbe.

Questa è la categoria in cui i tipi rendono di più: gli errori di forma dei dati, che attraversano molti passaggi prima di manifestarsi, e quando si manifestano lo fanno lontano da dove sono nati.

La classe di errori che i tipi non trovano#

Ed è qui che si concentra il fraintendimento più costoso. I tipi TypeScript non esistono a runtime: il compilatore li rimuove e produce JavaScript ordinario. Un'annotazione su un dato che arriva dall'esterno non è una verifica — è una dichiarazione di intenti.

confine.tsJavaScript
// Il malinteso più costoso: i tipi NON esistono a runtime. Sono cancellati// dal compilatore. Al confine con il mondo esterno non verificano nulla.const r = await fetch('/api/utente');const u: Utente = await r.json();     // ← BUGIA. json() restituisce any,                                      //   e "as Utente" è solo un desiderioconsole.log(u.nome.toUpperCase());    // esplode se l'API ha cambiato campo// ✓ Al confine si VALIDA, e il tipo si ricava dallo schema — una sola//   fonte di verità, invece di un tipo e un validatore da tenere allineati.import { z } from 'zod';const Utente = z.object({  id: z.string().uuid(),  nome: z.string().min(1),  email: z.string().email(),  creatoIl: z.coerce.date()});type Utente = z.infer<typeof Utente>;   // il tipo DERIVA dallo schemaconst u = Utente.parse(await r.json()); // valida davvero, a runtime

La riga const u: Utente = await r.json() è una bugia con la sintassi della verità. json() restituisce any: state dicendo al compilatore «fidati», e il compilatore si fida. Se l'API ha rinominato un campo la settimana scorsa, il programma esplode esattamente come farebbe in JavaScript — con la differenza che ora avevate l'impressione di essere protetti.

i confini del programma
Ogni punto in cui un dato entra nel programma è un confine, e in ogniconfine i tipi dichiarati sono un'ipotesi finché non li si verifica:  risposte HTTP di API esterne     ← cambiano senza avvisare  corpo delle richieste in arrivo  ← lo scrive l'utente  righe lette dal database         ← lo schema può essere disallineato  variabili d'ambiente             ← process.env è Record<string, string|undefined>  file JSON di configurazione      ← modificati a mano  messaggi da code e WebSocket     ← li produce un altro servizio  localStorage / sessionStorage    ← ci ha scritto una versione precedenteDentro il programma i tipi bastano. Sui confini serve la validazione.

Le opzioni che fanno la differenza#

tsconfig.jsonJavaScript
// tsconfig.json: le opzioni che fanno la differenza fra "TypeScript"// e "JavaScript con le annotazioni".{  "compilerOptions": {    "strict": true,                        // il minimo sindacale    // Le tre che strict NON accende e che valgono di più:    "noUncheckedIndexedAccess": true,      // arr[0] è T | undefined    "exactOptionalPropertyTypes": true,    // { a?: string } ≠ { a: undefined }    "noImplicitOverride": true,    "verbatimModuleSyntax": true,    "skipLibCheck": true                   // non tipizzare i .d.ts altrui:  }                                        // taglia i tempi di compilazione}// noUncheckedIndexedAccess è quella che trova più bug reali:const primo = elenco[0];        // senza: Utente     → primo.nome esplode                                // con:   Utente | undefined → l'editor lo dice

strict: true è il punto di partenza, non di arrivo. La più preziosa fra quelle che non include è noUncheckedIndexedAccess: senza, elenco[0] ha tipo Utente anche quando l'array è vuoto, e il classico Cannot read properties of undefined passa il controllo senza un avviso. Con l'opzione attiva il tipo diventa Utente | undefined e l'editor obbliga a gestire il caso.

any, e perché è peggio di quanto sembri#

any.tsJavaScript
// ✗ any spegne il controllore: e il contagio si propaga silenziosamente//   a tutto ciò che tocca.function elabora(d: any) {  return d.risultati.map(r => r.valore.trim());   // zero controlli, ovunque}// ✓ unknown obbliga a restringere prima di usare. È "any onesto".function elabora(d: unknown) {  if (!Array.isArray(d)) throw new TypeError('atteso un array');  return d.map(r => String(r));}// Nel tsconfig, per non lasciarne passare per distrazione://   "noImplicitAny": true   (incluso in strict)// E in revisione: un "as" merita un commento che dica perché quel cast// è sicuro. Se non si riesce a scriverlo, probabilmente non lo è.

any non è «un tipo permissivo»: è l'istruzione di smettere di controllare, e la disattivazione si propaga a tutto ciò che quel valore tocca. Un any in una funzione di utilità usata in venti punti disattiva il controllo in venti punti, senza che nessuno di quei venti se ne accorga.

unknown è il suo equivalente onesto: accetta qualunque cosa in ingresso, e non permette di farci niente finché non si è ristretto il tipo. Costringe a scrivere il controllo che any permetteva di saltare.

Migrare senza fermarsi#

migrazione
# La migrazione non è tutto-o-niente. Cinque passi, ognuno pubblicabile.# 1. TypeScript entra ma non impone nulla: i .js restano .jsnpm i -D typescript @types/nodenpx tsc --init# tsconfig.json: allowJs true, strict false, checkJs false# 2. Si rinomina UN file per volta, partendo dalle FOGLIE (utilità senza#    dipendenze): la loro tipizzazione si propaga a chi le usa.git mv src/utils/formatta.js src/utils/formatta.tsnpx tsc --noEmit# 3. Quando i .ts sono la maggioranza, si accende strict e si conta:npx tsc --noEmit 2>&1 | grep -c 'error TS'187# Un numero che deve solo scendere. Si mette in CI e non si accettano# aumenti: il debito si estingue senza fermare le funzionalita'.# 4. In pipeline, la barriera:npx tsc --noEmit || exit 1
Il numero di errori in CI è la metrica: deve solo scendere. Il debito si estingue senza mai bloccare lo sviluppo di funzionalità.

Due accorgimenti che fanno la differenza. Il primo: partire dalle foglie, cioè dai moduli di utilità che non dipendono da altro. La loro tipizzazione si propaga verso l'alto e rende più facile ogni file successivo; partire dall'alto significa invece scrivere decine di any per tutto ciò che non è ancora tipizzato.

Il secondo: non attivare strict subito. Convertire con strict: false e accenderlo quando la maggior parte dei file è già passata rende il lavoro incrementale invece di produrre un muro di errori il primo giorno.

Quando non serve#

alternative.jsJavaScript
// Dove TypeScript non ripaga, ed è onesto dirlo:////   - script usa e getta, da eseguire una volta e cancellare//   - una pagina con trenta righe di interattività//   - prototipi in cui la forma dei dati cambia ogni ora: i tipi//     diventano una zavorra da riscrivere invece di una rete//   - un team che non lo conosce e ha una consegna fra due settimane://     il costo di apprendimento è reale e cade nel momento sbagliato//// La via di mezzo che spesso è la risposta giusta: JSDoc + checkJs.// Zero passaggi di build, zero file rinominati, e l'editor controlla// comunque i tipi./** @param {number} prezzo @returns {number} */function conIva(prezzo) { return prezzo * 1.22; }

La via di mezzo merita più attenzione di quanta ne riceva. Con checkJs attivo, TypeScript legge i commenti JSDoc e fa il suo lavoro su file .js ordinari: nessun passaggio di compilazione, nessun file rinominato, nessuna modifica al deploy. Per un progetto Node piccolo o per uno script di build è spesso il rapporto costi/benefici migliore in assoluto.

SituazioneSceltaPerché
Applicazione che vivrà anni, più personeTypeScript strictil costo si ammortizza sulla manutenzione
Libreria pubblicata per altriTypeScripti tipi sono parte della documentazione dell'API
Progetto Node piccolo, una personaJSDoc + checkJs70% del beneficio, zero build
Script da eseguire una voltaJavaScripti tipi non fanno in tempo a ripagarsi
Prototipo con dati che cambiano ogni oraJavaScript, poi tipitipizzare qualcosa che non ha ancora forma è lavoro sprecato
Pagina con poca interattivitàJavaScriptla superficie è troppo piccola perché conti

La sintesi che uso per decidere: i tipi ripagano in proporzione a quanto a lungo il codice verrà letto e modificato da qualcuno che non lo ha scritto — compreso voi stessi fra otto mesi. Se quella durata è breve, non ripagano. Se è lunga, ripagano molto, ma solo a patto di ricordare che sui confini del programma servono controlli veri e non annotazioni.