Salta al contenuto principale

01 - TypeScript essentials per fullstack dev

Tipi strutturali, unioni discriminate, generics con vincoli, due flag che strict non attiva e il confine runtime: tutto compilato con TypeScript 7.0.2.

Cosa ne pensi di questo articolo?

01 - TypeScript essentials per fullstack dev
Sviluppo Web Fullstackfedericocalo.dev

In Breve

Tipi strutturali, unioni discriminate, generics con vincoli, due flag che strict non attiva e il confine runtime: tutto compilato con TypeScript 7.0.2.

TypeScript controlla il tuo programma prima che giri e poi sparisce: ciò che arriva in produzione è JavaScript, senza un solo tipo. Da questa frase discendono i due errori più comuni di chi arriva dal JavaScript. Il primo è pensare che i tipi proteggano anche i dati che entrano da fuori (una risposta HTTP, un file JSON, un campo di un form), mentre non toccano niente di ciò che esiste solo a runtime. Il secondo è usarli come documentazione e basta, rinunciando a farsi dire dal compilatore che cosa hai dimenticato di gestire. Questo primo capitolo della serie Sviluppo Web Fullstack mostra quattro meccanismi che cambiano il modo di scrivere codice e un confine che nessun tipo attraversa. Ogni listato è stato compilato e, dove produce un risultato, eseguito.

Il banco di prova è un piccolo progetto mio con TypeScript 7.0.2 (versione latest sul registro npm il 13 novembre 2026) e Node.js 24.11. Il compilatore fa solo il controllo (noEmit); l'esecuzione la fa Node, che da solo toglie i tipi e lancia il file .ts. Nessun browser e nessun server sono stati usati, e non serve nessuno dei due per quello che segue.

Il compilatore guarda la forma, non il nome

In TypeScript due tipi sono compatibili se hanno le stesse proprietà, indipendentemente da come si chiamano e da dove sono dichiarati: è il typing strutturale. Per una fattura con un importo e una valuta, qualunque oggetto con quei campi è una fattura, anche se nasce da una query o da un CSV. Il vantaggio è che non devi dichiarare che un tipo «implementa» un altro; il rischio è che il compilatore accetta oggetti con più campi di quelli che dichiari, perché un oggetto con un campo in più è comunque una fattura valida.

interface Money { amount: number; currency: string }
interface Invoice { id: string; total: Money }

// Non è un letterale: nessun controllo sulle proprietà in eccesso
const row = { id: "F-1", total: { amount: 120, currency: "EUR" }, note: "pagata" };
const invoice: Invoice = row;

console.log(invoice.id, invoice.total.amount);
console.log(Object.keys(invoice));

// Output:
// F-1 120
// [ 'id', 'total', 'note' ]

Nel listato row ha un campo note che Invoice non conosce, e l'assegnazione const invoice: Invoice = row passa lo stesso: la variabile intermedia non è un letterale, quindi non scatta il controllo sulle proprietà in eccesso. L'uscita lo conferma a runtime: Object.keys mostra ancora note. Il tipo restringe ciò che il compilatore ti lascia leggere, non ciò che l'oggetto contiene davvero. Conseguenza pratica: se serializzi quell'oggetto in una risposta, il campo extra esce comunque.

Quando invece scrivi il letterale direttamente nell'assegnazione, il compilatore diventa severo, perché lì un campo sconosciuto è quasi sempre un refuso. Con gli stessi due tipi, due righe sbagliate producono due errori distinti (comando: tsc -p tsconfig.err.json --pretty false).

interface Money { amount: number; currency: string }
interface Invoice { id: string; total: Money }

const literal: Invoice = { id: "F-2", total: { amount: 10, currency: "EUR" }, note: "x" };
const missing: Invoice = { id: "F-3" };

// Errore di tsc:
// errs/s1_err.ts(4,79): error TS2353: Object literal may only specify known properties, and 'note' does not exist in type 'Invoice'.
// errs/s1_err.ts(5,7): error TS2741: Property 'total' is missing in type '{ id: string; }' but required in type 'Invoice'.

Il primo messaggio (TS2353) è il controllo sul letterale: indica la colonna esatta del campo note. Il secondo (TS2741) dice quale proprietà obbligatoria manca. Leggere il numero del codice è utile in pratica: lo cerchi nella documentazione o nel tuo editor e trovi subito la spiegazione, senza fidarti del testo del messaggio, che può cambiare fra versioni.

Unioni discriminate: lasciare al compilatore il conto dei casi

Un pagamento può essere con carta, con bonifico o in contanti, e ciascun metodo porta dati diversi: le ultime quattro cifre per la carta, l'IBAN per il bonifico, niente per i contanti. Modellarlo con un solo oggetto pieno di campi opzionali ti obbliga a ricordare quale combinazione è lecita. L'unione discriminata la rende esplicita: ogni variante ha una proprietà letterale in comune (qui kind) e il compilatore, controllandola in un switch, sa quali altri campi esistono in ciascun ramo.

type Payment =
  | { kind: "card"; last4: string }
  | { kind: "iban"; iban: string }
  | { kind: "cash" };

function describe(p: Payment): string {
  switch (p.kind) {
    case "card":
      return `carta ****${p.last4}`;
    case "iban":
      return `bonifico ${p.iban.slice(0, 4)}...`;
    case "cash":
      return "contanti";
    default: {
      const unreachable: never = p;
      return unreachable;
    }
  }
}

const payments: Payment[] = [
  { kind: "card", last4: "4242" },
  { kind: "iban", iban: "IT60X0542811101000000123456" },
  { kind: "cash" },
];
for (const p of payments) console.log(describe(p));

// Output:
// carta ****4242
// bonifico IT60...
// contanti

La riga che conta è quella nel ramo default: const unreachable: never = p. Se tutti i casi sono gestiti, a quel punto p ha tipo never e l'assegnazione è lecita; se ne manca uno, p ha ancora un tipo e l'assegnazione fallisce. Dentro case "iban" puoi chiamare p.iban.slice(0, 4) senza controlli, perché il compilatore sa che in quel ramo il campo esiste. La tecnica è descritta nell'Handbook alla voce «Exhaustiveness checking», con lo stesso schema never nel ramo default.

Il vantaggio si vede quando il codice cambia. Aggiungi la variante paypal all'unione senza toccare la funzione e il compilatore indica la funzione da aggiornare, invece di lasciare che il nuovo metodo cada nel ramo default e produca un risultato sbagliato in silenzio.

type Payment =
  | { kind: "card"; last4: string }
  | { kind: "iban"; iban: string }
  | { kind: "cash" }
  | { kind: "paypal"; email: string };
// ... function describe invariata, con lo stesso default: never

// Errore di tsc:
// errs/s2_err.ts(16,13): error TS2322: Type '{ kind: "paypal"; email: string; }' is not assignable to type 'never'.

L'errore TS2322 compare nella riga del default, non nel punto in cui hai aggiunto la variante: è il punto in cui la funzione non sa più gestire tutti i casi. Se il tuo progetto ha venti funzioni che fanno switch su un tipo di evento, aggiungere un evento ti restituisce la lista di tutte e venti. Non usare questo schema quando un caso non gestito è accettabile: in quel caso un default che restituisce un valore neutro è la scelta onesta.

Generics: scrivere la funzione una volta, per più tipi

Una funzione che estrae due campi da una fattura e una che li estrae da un cliente sono la stessa funzione scritta due volte. Con un parametro di tipo (T) scrivi la logica una volta e il compilatore ricava il resto dai tipi che passi. Un parametro di tipo da solo, però, accetta tutto: per dire «questa chiave deve esistere in quell'oggetto» serve un vincolo, scritto con extends. Nel listato che segue K extends keyof T impone che ogni chiave sia una proprietà reale di T.

interface Invoice { id: string; customer: string; total: number; paid: boolean }

function pick<T, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

function groupBy<T, K extends PropertyKey>(
  items: readonly T[],
  key: (item: T) => K,
): Partial<Record<K, T[]>> {
  const out: Partial<Record<K, T[]>> = {};
  for (const item of items) {
    (out[key(item)] ??= []).push(item);
  }
  return out;
}

type Nullable<T> = { [P in keyof T]: T[P] | null };

const invoices: Invoice[] = [
  { id: "F-1", customer: "Rossi", total: 120, paid: true },
  { id: "F-2", customer: "Bianchi", total: 80, paid: false },
  { id: "F-3", customer: "Rossi", total: 45, paid: false },
];

const summary = pick(invoices[0]!, ["id", "total"]);
console.log(summary);

const byCustomer = groupBy(invoices, (i) => i.customer);
console.log(Object.entries(byCustomer).map(([c, list]) => `${c}: ${list?.length ?? 0}`));

const draft: Nullable<Invoice> = { id: null, customer: "Verdi", total: null, paid: null };
console.log(draft);

// Output:
// { id: 'F-1', total: 120 }
// [ 'Rossi: 2', 'Bianchi: 1' ]
// { id: null, customer: 'Verdi', total: null, paid: null }

Tre funzioni, tre idee diverse. pick restituisce un oggetto con il solo sottoinsieme di campi richiesto e il tipo del risultato (Pick<T, K>) lo riflette: summary.customer sarebbe un errore. groupBy raggruppa per una chiave calcolata. Il suo tipo di ritorno è Partial<Record<K, T[]>> e non Record<K, T[]> per un motivo preciso: se K è un'unione di valori possibili, nulla garantisce che ognuno compaia davvero, e infatti nel codice ho dovuto scrivere list?.length ?? 0, perché il compilatore segnala che la lista può mancare. Il tipo Nullable<T> è un mapped type: itera sulle chiavi di T e a ciascun tipo aggiunge null.

Se sbagli una chiave il compilatore ti ferma prima che il programma giri. Con pick(inv, ["id", "tot"]), dove tot non esiste in Invoice, ottieni:

declare const inv: Invoice;
pick(inv, ["id", "tot"]);

// Errore di tsc:
// errs/s3_err.ts(10,18): error TS2322: Type '"tot"' is not assignable to type 'keyof Invoice'.

(I numeri di riga degli errori si riferiscono ai file completi, qui ridotti all'essenziale.) Il messaggio ti dice che "tot" non è assegnabile a keyof Invoice, cioè all'unione dei nomi di proprietà "id" | "customer" | "total" | "paid". Nei progetti reali questo vincolo protegge i selettori di campo, le colonne di ordinamento, le chiavi di traduzione: ovunque una stringa deve essere uno fra un insieme noto. Il generico non serve quando la funzione ha un solo tipo di uso: un parametro di tipo che compare una volta sola nella firma è quasi sempre un segnale che il tipo concreto basta.

I tipi di utilità che userai ogni giorno

La libreria standard di TypeScript contiene già i mapped type più comuni, e conviene conoscerli prima di scriverne di propri. Quelli elencati nella pagina «Utility Types» dell'Handbook e usati più spesso con le API sono questi:

  • Partial<T> rende opzionali tutte le proprietà di T: serve per le richieste di aggiornamento parziale.
  • Required<T> fa l'opposto e rende obbligatorie tutte le proprietà.
  • Readonly<T> impedisce la riassegnazione delle proprietà, solo a livello di tipo.
  • Pick<T, K> tiene solo le proprietà indicate in K.
  • Omit<T, K> toglie quelle indicate in K.
  • Record<K, V> costruisce un oggetto con le chiavi K e valori di tipo V.

L'ordine delle voci qui non conta, quindi l'elenco è un semplice catalogo. Quello che ti serve ricordare è il criterio di scelta: se parti da un tipo completo e vuoi una variante più piccola, usa Pick o Omit; se la variante ha gli stessi campi con regole diverse, usa Partial, Required o Readonly. Un'avvertenza: Readonly esiste solo nel compilatore e a runtime non fa nulla, per congelare davvero un oggetto serve Object.freeze.

Due flag che strict non accende

Nella documentazione dell'opzione strict si legge che attiva «tutta la famiglia» dei controlli rigorosi e che versioni future potrebbero aggiungerne altri. Due controlli molto utili non ne fanno parte e vanno chiesti a mano. Ho verificato: con la sola opzione --strict, il file seguente compila senza errori. Con le due opzioni attive nel mio tsconfig.json ne produce due.

const prices: number[] = [19.9, 5];
const first = prices[0];
console.log(first.toFixed(2));

interface Filter { q?: string }
const f: Filter = { q: undefined };

// Errore di tsc:
// errs/s4_err.ts(3,13): error TS18048: 'first' is possibly 'undefined'.
// errs/s4_err.ts(6,7): error TS2375: Type '{ q: undefined; }' is not assignable to type 'Filter' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
//   Types of property 'q' are incompatible.
//     Type 'undefined' is not assignable to type 'string'.

La prima riga dell'errore è effetto di noUncheckedIndexedAccess, introdotta con TypeScript 4.2: leggere prices[0] restituisce number | undefined invece di number, perché un array può essere vuoto. Il compilatore ti obbliga a decidere che cosa fare in quel caso. Il secondo errore viene da exactOptionalPropertyTypes: con la proprietà opzionale q?: string la chiave può mancare, ma non può esserci con valore undefined. La distinzione conta quando serializzi: "q" in oggetto e Object.keys vedono una chiave presente con valore undefined (l'ho verificato in Node), mentre una chiave assente non compare, e due oggetti che sembrano uguali si comportano in modo diverso.

Il codice che compila con le due opzioni è il seguente: nel primo caso gestisci l'array vuoto, nel secondo scegli se la chiave c'è oppure no, e l'operatore in ne dà prova.

const prices: number[] = [19.9, 5];
const first = prices[0];
console.log(first === undefined ? "lista vuota" : first.toFixed(2));

interface Filter { q?: string }
const withQuery: Filter = { q: "rossi" };
const withoutQuery: Filter = {};
console.log("q" in withQuery, "q" in withoutQuery);

// Output:
// 19.90
// true false

Il costo è reale e va detto: noUncheckedIndexedAccess riempie di controlli il codice che scorre array con indici, e in un progetto esistente può produrre centinaia di errori in un colpo. Se parti da zero, accendila subito; se migri, attivala per cartella usando un file di configurazione secondario che estende il primo.

Il confine che i tipi non attraversano

Tutto quello che hai visto finora vive nel compilatore. Un dato che arriva da un fetch, da un file o da un form esiste solo a runtime, e il compilatore non ha modo di verificarlo. Il caso tipico è JSON.parse(testo) as Order: l'asserzione as non controlla nulla, dice al compilatore di fidarsi. Il listato mostra la differenza con un payload dove id e qty arrivano come stringhe, come succede con un form o con un servizio mal scritto.

interface Order { id: number; email: string; qty: number }

const payload = '{"id":"7","email":"a@b.it","qty":"3"}';

// Il cast dice al compilatore ciò che vuole sentire: a runtime non controlla niente
const trusted = JSON.parse(payload) as Order;
console.log(typeof trusted.qty, trusted.qty + 1);

function parseOrder(input: unknown): Order {
  if (typeof input !== "object" || input === null) throw new TypeError("order: oggetto atteso");
  const o = input as Record<string, unknown>;
  if (typeof o.id !== "number" || !Number.isInteger(o.id)) throw new TypeError("order.id: intero atteso");
  if (typeof o.email !== "string" || !o.email.includes("@")) throw new TypeError("order.email: indirizzo atteso");
  if (typeof o.qty !== "number" || o.qty < 1) throw new TypeError("order.qty: numero >= 1 atteso");
  return { id: o.id, email: o.email, qty: o.qty };
}

try {
  parseOrder(JSON.parse(payload));
} catch (e) {
  console.log((e as Error).message);
}
console.log(parseOrder(JSON.parse('{"id":7,"email":"a@b.it","qty":3}')));

// Output:
// string 31
// order.id: intero atteso
// { id: 7, email: 'a@b.it', qty: 3 }

La prima riga stampata è la prova: trusted.qty è dichiarato number ma a runtime è una stringa, e trusted.qty + 1 vale "31" invece di 4. Nessun errore, nessun avviso: il tipo mentiva. La funzione parseOrder accetta unknown, controlla uno per uno i campi e rifiuta il payload con un messaggio che nomina il campo sbagliato (order.id: intero atteso); solo dopo i controlli il compilatore ti lascia trattare il valore come Order. Questa funzione ha un costo: va tenuta allineata al tipo. Per questo molti progetti generano la validazione e il tipo dalla stessa definizione, con una libreria di schemi, oppure derivano il tipo da uno schema OpenAPI come vedrai nel capitolo su GraphQL e REST.

La configurazione usata per tutti i listati

Questo è il file tsconfig.json con cui ho compilato, copiato com'è. Le uniche opzioni oltre a quelle di base sono le due già viste e erasableSyntaxOnly, che fa segnalare al compilatore le costruzioni che Node non sa togliere (enum, namespace con codice, proprietà dichiarate nei parametri del costruttore): la documentazione dell'opzione spiega che serve proprio a poter eseguire i file direttamente in Node.

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "erasableSyntaxOnly": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

Con questa configurazione npx tsc -p tsconfig.json controlla i tipi e termina senza errori, e node file.ts esegue lo stesso file. Sono due strumenti con due compiti: Node toglie i tipi senza controllarli (la sua documentazione lo dice esplicitamente: nessun type checking), quindi un errore di tipo non ferma l'esecuzione. Per questo nella pipeline il controllo dei tipi è un passo a parte, che deve fallire prima del rilascio.

Domande frequenti

TypeScript controlla i dati che arrivano da un'API?

No. I tipi esistono solo in compilazione: un dato che arriva a runtime non viene controllato, e un'asserzione as non cambia questo. Per i dati esterni serve una funzione di validazione che parta da unknown, come parseOrder nel listato, oppure una libreria di schemi.

Conviene attivare noUncheckedIndexedAccess su un progetto esistente?

Dipende dal costo: aggiunge controlli su ogni lettura per indice e in un codice grande produce molti errori. Su un progetto nuovo conviene accenderla subito; su uno esistente si può attivare in una configurazione secondaria che estende la principale e si applica a una cartella alla volta.

Node può eseguire TypeScript senza compilare?

Sì, per la sintassi che si può cancellare senza cambiare il significato. La documentazione di Node dice che sostituisce i tipi con spazi e non fa alcun controllo; enum e namespace con codice richiedono invece una trasformazione. L'opzione erasableSyntaxOnly di TypeScript segnala le costruzioni non supportate.

Che cosa sai fare adesso

Sai leggere un errore di TypeScript dal suo codice e dalla colonna indicata, modellare una scelta fra alternative con un'unione discriminata e lasciare al compilatore il compito di avvisarti quando ne aggiungi una. Sai vincolare un generico con keyof, scegliere fra i tipi di utilità della libreria standard e accendere i due controlli che strict lascia spenti. Soprattutto sai dove finisce la garanzia: ai bordi del programma serve una validazione scritta da te, non un cast. Il prossimo capitolo, Angular SSR, componenti standalone e signals, usa questi strumenti sul lato client.

La serie si chiude con CI/CD multi-ambiente, che ne raccoglie l'elenco degli otto articoli.

Fonti

  • TSConfig Reference - definizione di strict, noUncheckedIndexedAccess (dalla 4.2), exactOptionalPropertyTypes ed erasableSyntaxOnly, pagina scaricata e letta il 2026-11-13
  • Handbook, Narrowing - sezione «Exhaustiveness checking» con il tipo never nel ramo default, letta il 2026-11-13
  • Handbook, Utility Types - definizione di Partial, Required, Readonly, Pick, Omit, Record, letta il 2026-11-13
  • Handbook, Generics - parametri di tipo e vincoli con extends, pagina scaricata il 2026-11-13
  • Node.js, TypeScript (documentazione ufficiale) - type stripping senza controllo dei tipi, cronologia delle versioni (abilitato di default dalla 23.6 e dalla 22.18, stabile dalla 24.12 e dalla 25.2), letta il 2026-11-13
  • Registro npm, pacchetto typescript - versione latest 7.0.2, interrogata con npm view typescript dist-tags il 2026-11-13

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti