Lavorate nello sviluppo web? Vi siete mai trovati a lavorare su un progetto JavaScript relativamente complesso, come un server Node.js oppure un intricato portale React? Avete mai avuto difficoltà dovute al linguaggio che vi hanno fatto perdere tempo e capelli per debuggare errori particolarmente subdoli?
Beh, noi sÃŽ. Parecchie volte, in effetti. Abbastanza da farmi chiedere se esista uno strumento migliore per sviluppare e mantenere certe applicazioni. Qualcosa che renda piÃđ semplice trovare errori con la gestione e lâutilizzo delle strutture dati, per esempio.
Per fortuna, la risposta ÃĻ sÃŽ, e questo strumento ÃĻ TypeScript! Estensione open source di JavaScript sviluppata principalmente da Microsoft, TypeScript aggiunge al linguaggio di programmazione preferito dei browser web molti nuovi strumenti e funzionalità , in particolare la tipizzazione statica, per rendere i progetti piÃđ robusti e facili da mantenere.
Molto probabilmente tutto questo lo sapete già , soprattutto se frequentate questo blog. Non solo per lâincredibile diffusione che ha raggiunto TypeScript negli ultimi anni, ma anche perchÃĐ proprio su queste pagine era già uscito un mio articolo in cui presentavo alcune interessanti funzionalità di questo linguaggio. Avevo ipotizzato che prima o poi potesse uscire un sequel, e, puntuale come un errore âCannot read property of undefinedâ in unâapplicazione JavaScript, eccolo qua.
Ripartiamo subito, allora. Cinque paragrafi, cinque strumenti piÃđ o meno noti di TypeScript che potrebbero sorprendervi, con esempi e link al playground ufficiale per la versione 5.1.
Tuple
Questo ÃĻ molto semplice, ma credo comunque che per qualcuno sarà una novità .
Chiunque abbia lavorato con TypeScript sa come tipizzare staticamente gli array: ÃĻ sufficiente definire il tipo del singolo elemento, e aggiungere le parentesi quadre dopo di esso.
const numberArray: number[] = [];
// Validi:
numberArray.push(1);
numberArray.push(2);
// Non valido:
numberArray.push("string");
La cosa non altrettanto ovvia ÃĻ che ÃĻ possibile utilizzare le definizioni di tipo anche per creare in modo molto semplice delle tuple – già menzionate nel primo articolo -, o ennuple se preferite, ovvero strutture dati formate da una combinazione ordinata di elementi. Ecco un esempio di combinazione formata da tre numeri.
const numberArray: number[] = [];
// Validi:
numberArray.push(1);
numberArray.push(2);
// Non valido:
numberArray.push("string");
Il vantaggio delle tuple ÃĻ che, come si vede dallo snippet di codice, la tipizzazione statica del compilatore TypeScript andrà a validare anche la cardinalità degli elementi, e non soltanto il loro tipo, assicurandoci quindi che la struttura dati contenga sempre tutti e soli gli elementi che ci aspettiamo.
Le tuple hanno molte applicazioni nei progetti software. In React, ad esempio, sono molto utili per definire variabili di stato che raccolgono in una sola semplice struttura dati piÃđ valori strettamente legati fra loro, cosÃŽ da poterla leggere e aggiornare senza rendere lo stato del relativo componente troppo ingombrante e verboso, soprattutto con gli hook.
A questo proposito, vale la pena di specificare che le tuple possono contenere dati eterogenei di qualsiasi tipo, anche complessi – incluse altre tuple, se volete metterci un poâ di creatività !
interface ResponseBody {
title: string
content: string
}
// Questa tupla contiene un codice HTTP, un
// messaggio di risposta e il body di risposta.
const apiResponse: [number, string, ResponseBody] = [
200,
"success",
{
title: "Titolo",
content: "Contenuto",
},
];
// Recupero i singoli elementi tramite destructuring
// (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment).
const [statusCode, message, body] = apiResponse;
console.log(statusCode);
console.log(message);
console.log(body);
Utility type: Readonly e NonNullable
Utility type! Ve li ricordate? Avevo già presentato un paio di questi costrutti modificatori di tipo nellâarticolo precedente. Oggi ne vediamo altri due, spesso ignorati ma non per questo poco utili.
Readonly ÃĻ un utility type che, preso un tipo, lo trasforma nel suo corrispondente in sola lettura. Nel caso dellâinterfaccia per un oggetto, ad esempio, questo utility type permette di modificare quella interfaccia in modo che nessuna delle proprietà dellâoggetto possa essere riassegnata dopo la creazione.
// Interfaccia per un oggetto con ID e titolo.
interface SomeInterface {
id: number
title: string
}
// Oggetto SomeInterface:
const someObject: SomeInterface = {
id: 1,
title: "Titolo",
};
// Oggetto SomeInterface in sola lettura:
const someReadonlyObject: Readonly<SomeInterface> = {
id: 2,
title: "Titolo in sola lettura",
};
// Valido:
someObject.title = "Altro titolo";
// Non valido:
someReadonlyObject = "Altro titolo ancora";
Questa funzionalità puÃē sembrare relativamente inutile a prima vista, ma pensate a quante volte si ha a che fare con valori che dovrebbero restare immutabili: oggetti che contengono configurazioni di qualche tipo, lo stato di un componente React, lo stato di uno store Redux o tutte quelle librerie che si basano sulla comparazione standard di JavaScript per scatenare determinati effetti nellâapplicazione. Grazie a Readonly, ÃĻ il compilatore stesso che puÃē aiutarvi a gestire questi valori nel modo corretto, lanciando un errore quando tentate di assegnare una variabile che non dovrebbe essere riassegnata. à come un const, ma che va piÃđ in profondità !
NonNullable ÃĻ un utility type che esclude da uno union type i tipi null o undefined. Credo che questo sia abbastanza autoesplicativo: utile quando un certo tipo, come quello della proprietà di unâinterfaccia, prevede la possibilità che un valore sia vuoto, ma abbiamo bisogno di inizializzare una nuova variabile che invece deve essere valorizzata.
interface SomeInterface {
// In questa interfaccia, value puÃē essere vuoto.
value: number | null | undefined
}
// Inizializzo esternamente una variabile da usare come
// value, ma stavolta voglio che sia valorizzata.
// Valido:
const val1: SomeInterface["value"] = null;
// Non valido:
const val2: NonNullable<SomeInterface["value"]> = null;
// Valido:
const val3: NonNullable<SomeInterface["value"]> = 10;
Generics: default, vincoli, condizioni
Per chi ha utilizzato linguaggi di programmazione come Java o C# – noto anche come Microsoft Java -, i generics non hanno bisogno di presentazioni. Per chiunque invece avesse ancora un poâ della propria sanità mentale, i generics permettono di creare componenti software che possono accettare una varietà di tipi diversi, dove il tipo specifico sarà fornito da chi utilizza il componente stesso, pur mantenendo tutti i vantaggi della tipizzazione statica.
interface SomeInterface {
// In questa interfaccia, value puÃē essere vuoto.
value: number | null | undefined
}
// Inizializzo esternamente una variabile da usare come
// value, ma stavolta voglio che sia valorizzata.
// Valido:
const val1: SomeInterface["value"] = null;
// Non valido:
const val2: NonNullable<SomeInterface["value"]> = null;
// Valido:
const val3: NonNullable<SomeInterface["value"]> = 10;
La documentazione ufficiale parla estensivamente dei generics, per cui vi consiglio di dare unâocchiata là se vi servisse unâintroduzione piÃđ rigorosa. Qui mi limiterÃē a citare alcune interessanti funzionalità a tema.
Per esempio: i generics si possono rendere opzionali fornendo un valore di default nella dichiarazione del componente, come con i parametri di una funzione. Se nessun tipo ÃĻ specificato quando il componente viene utilizzato, il compilatore prenderà quello di default.
// string ÃĻ il tipo di default per questa interfaccia.
interface GenericInterface<T = string> {
param: T
}
// Validi:
const value1: GenericInterface = {
param: "stringa"
}
const value2: GenericInterface<number> = {
param: 42
}
// Non valido: TypeScript assume che param sia di tipo stringa.
const value3: GenericInterface = {
param: 42
}
Questa funzionalità puÃē rivelarsi utile per introdurre lâuso dei generics in un componente che al momento non ne ha, oppure per aggiungerne di nuovi, senza intaccare la sua retrocompatibilità .
Attenzione perÃē: specificare un tipo di default non significa mettere un vincolo a quali tipi possano essere specificati sul componente. Il tipo fornito al momento dellâuso, infatti, potrebbe anche essere completamente diverso da quello di default, come visto sopra con number e string.
Ecco perchÃĐ sarebbe sbagliato scrivere questo.
// Un'interfaccia della nostra applicazione.
interface SomeInterface {
id: number
name: string
}
// Funzione che usa un tipo generico, con l'interfaccia come default.
function someFunction<T = SomeInterface>(param: T): void {
// Non valido: non c'ÃĻ garanzia che T e SomeInterface saranno compatibili.
console.log(param.name);
}
Per queste situazioni, ÃĻ possibile imporre un vincolo al tipo che potrà essere accettato dal componente con extends, che puÃē anche essere combinato con un tipo di default.
// Un'interfaccia della nostra applicazione.
interface SomeInterface {
id: number
name: string
}
// Funzione che usa un tipo generico che estende SomeInterface.
function someFunction<T extends SomeInterface = SomeInterface>(
param: T
): void {
// Valido: qualsiasi tipo sia T, sarÃ
// un'estensione di SomeInterface.
console.log(param.name);
}
// Non valido: il tipo non ÃĻ compatibile.
someFunction<number>(42);
Per casi particolarmente complessi, ÃĻ possibile anche usare delle condizioni per modificare la tipizzazione statica di altre parti del componente a partire dai generics. Un esempio classico: mettiamo di avere un componente che gestisce un valore, e questo valore puÃē essere un singolo elemento cosÃŽ come un array di elementi.
Con i generics, possiamo gestire il tutto con una sola interfaccia:
- definiamo unâinterfaccia con due tipi generici, il tipo del valore T e un tipo Multiple vincolato su un booleano;
- creiamo nellâinterfaccia le proprietà value e onChange, rispettivamente valore e callback per quando il valore cambia;
- poniamo una condizione per il tipo delle proprietà in modo che cambi a seconda del tipo fornito per Multiple, sempre con extends.
Ecco il risultato.
// Multiple vincolato su boolean, non multiplo come default.
interface ComponentProps<T, Multiple extends boolean = false> {
// Il valore ÃĻ un singolo elemento oppure un array.
value: Multiple extends false ? T : T[]
// onChange ha come parametro un singolo elemento oppure un array.
onChange: (newValue: Multiple extends false ? T : T[]) => void
}
// Oggetto per il caso singolo (lascio il default su Multiple):
const singleValueComponentProps: ComponentProps<string> = {
value: "Sono una singola stringa!",
onChange: (newValue) => {
console.log(
"Questo log stamperà sempre TRUE:",
typeof newValue === "string"
);
},
};
// Oggetto per il caso multiplo:
const multipleValueComponentProps: ComponentProps<string, true> = {
value: ["Sono", "un", "array", "di", "stringhe", "ora!"],
onChange: (newValue) => {
console.log(
"Posso usare i metodi di un array, perchÃĐ newValue ÃĻ un array:"
);
newValue.forEach((currentValue) => console.log(currentValue));
},
}
Type narrowing: type predicate
Dopo il primo articolo, torniamo a parlare di type narrowing, questa volta facendo un poâ piÃđ di giustizia a questa importantissima funzionalità .
La scorsa volta, parlando delle discriminated union, avevo solo brevemente menzionato lâargomento del type narrowing, dicendo in genere che si tratta del meccanismo con cui TypeScript riesce a dedurre, dalla tipizzazione che definiamo e dal flusso della nostra applicazione, quale tipo avrà una specifica variabile a runtime, cosÃŽ da presentarci gli errori appropriati durante la compilazione. Quello che non avevo detto ÃĻ quanto dannatamente potente e onnipresente sia questo meccanismo nelle applicazioni TypeScript, e quanto spesso lo utilizziamo senza nemmeno rendercene conto.
Pensiamo a una funzione con un parametro che puÃē essere una stringa oppure un numero. Se riceviamo una stringa, vogliamo stampare la sua lunghezza; se invece riceviamo un numero, vogliamo stamparne il valore in notazione puntata. La nostra conoscenza di JavaScript ci porta naturalmente a scrivere del codice come questo, usando lâoperatore typeof.
function someFunction (value: string | number): void {
if (typeof value === "string") {
// Stampo la lunghezza:
console.log("Lunghezza della stringa:", value.length);
} else {
// Stampo il valore:
console.log("Valore:", value.toFixed());
}
}
Molto semplice, vero? Eppure, diverse cose tuttâaltro che banali stanno succedendo sotto la superficie di questo snippet.
Allâinizio della funzione, abbiamo indicato che il parametro value puÃē essere una stringa oppure un numero, tramite uno dei nostri adorabili union type. Ma, nei due rami dellâif, stiamo usando delle funzionalità non comuni a questi due tipi: nel ramo then usiamo length, proprietà che non esiste nel tipo number, mentre nel ramo else usiamo toFixed, metodo che non esiste nel tipo string.
Se proviamo a eliminare lâif, vediamo che entrambi i suoi rami restituiscono giustamente un errore.
function someFunction (value: string | number): void {
// Stampo la lunghezza:
console.log("Lunghezza della stringa:", value.length);
// Stampo il valore formattato:
console.log("Valore:", value.toFixed());
}
Nella prima versione, perÃē, TypeScript non segnala nessuna anomalia. Cosa câÃĻ sotto?
La risposta sta proprio nel type narrowing. TypeScript esamina il nostro codice e, dagli operatori che usiamo e dal flusso dellâapplicazione, si rende conto che in determinati punti dellâesecuzione un certo valore avrà un tipo piÃđ specifico rispetto a quello che abbiamo dichiarato.
Lâoperatore typeof, ad esempio, costituisce quello che si chiama una type guard, ovvero un operatore di controllo speciale che ha effetto sul tipo che TypeScript deduce per un certo valore. Ecco perchÃĐ la prima versione della funzione sopra non restituisce errori: partendo dallo union type string | number, TypeScript vede la condizione dellâif e capisce non soltanto che nel ramo then value sarà certamente di tipo string, ma anche che nel ramo else value sarà per esclusione di tipo number.
Il type narrowing ÃĻ cruciale per le applicazioni TypeScript, e si applica a un gran numero di costrutti diversi: i typeof, ma anche i confronti, gli assegnamenti, operatori come in e instanceof, negli if, negli switchâĶ
Ma voi non siete qui per lunghe spiegazioni sul funzionamento di TypeScript. Voi volete qualche dritta su quelle funzionalità che potete usare nel vostro codice e raccontare alle feste per ottenere credito, rispetto e ammirazione dai vostri amici, e io vi accontento subito.
(Nota: la conoscenza di quanto spiegato di seguito potrebbe non farvi effettivamente ottenere credito, rispetto e ammirazione dai vostri amici. Lâautore dellâarticolo declina ogni responsabilità riguardo la scarsa riuscita della vostra vita sociale.)
Ci sono molti casi in cui sfruttare il type narrowing non ÃĻ cosÃŽ banale. Certo, finchÃĐ si parla di tipi primitivi come stringhe e numeri, oppure di utility built-in come Date, allora ÃĻ tutto molto semplice, maâĶ se ci fosse bisogno di lavorare con interfacce definite da noi?
Tenete a mente che le interfacce TypeScript hanno un piccolo grande problema: non esistono a runtime. In effetti, non esistono e basta, dato che le interfacce al momento non esistono in JavaScript. Sono un aiuto per il compilatore per rilevare errori di tipo statici, ma vengono eliminate nel corso della compilazione. Quindi no, non possiamo semplicemente usare instanceof come faremmo con le interfacce di altri linguaggi orientati agli oggetti; dobbiamo metterci un poâ di impegno in piÃđ.
Un caso tipico: abbiamo unâinterfaccia SomeInterface, una seconda interfaccia SomeExtension che estende la prima e una funzione che prende oggetti di tipo SomeInterface. Se lâoggetto che riceviamo ha tipo SomeExtension, vogliamo fare delle operazioni supplementari allâinterno della funzione. Peccato che lâoperatore in non basti a convincere TypeScript delle nostre buone intenzioni.
interface SomeInterface {
id: number
title: string
}
interface SomeExtension extends SomeInterface {
description: string
content: string
otherFields: Record<string, any>
}
function someFunction (obj: SomeInterface): void {
// Stampo tutte le proprietà .
// Validi:
console.log(obj.id);
console.log(obj.title);
if ("description" in obj) {
// L'operatore in ci permette di accedere a description...
console.log(obj.description);
// ...ma non basta a far riconoscere obj
// come oggetto di tipo SomeExtension.
console.log(obj.content);
console.log(obj.otherFields);
}
}
(Fino a TypeScript 4.8, avremmo avuto un errore anche per lâaccesso a description. Nella versione 4.9 câÃĻ stata qualche modifica al type narrowing, che permette di accedere in sicurezza al campo specificamente testato con in, anche se il tipo dedotto sarà unknown.)
Quello che dobbiamo fare qui ÃĻ far capire a TypeScript che il nostro accesso alle proprietà dellâestensione ÃĻ giustificato, o, se preferite, che stiamo usando queste proprietà solo quando obj ÃĻ effettivamente di tipo SomeExtension. Per fortuna, questo ÃĻ possibile con i type predicate.
In sostanza, i type predicate ci permettono di definire delle type guard personalizzate, con condizioni arbitrariamente complesse, che garantiscono a TypeScript che in un certo blocco della nostra applicazione una determinata variabile sia di un certo tipo – proprio come lâoperatore typeof, ma con una logica interamente definita da noi.
Creiamo una funzione isSomeExtension, che prende in ingresso un parametro di tipo SomeInterface e restituisce un type predicate che stabilisce che il parametro in ingresso ÃĻ di tipo SomeExtension. Nel body, la funzione deve esaminare il parametro e restituire true se il predicato in uscita ÃĻ valido, false altrimenti.
Infine, usiamo quella funzione nella condizione di someFunction.
interface SomeInterface {
id: number
title: string
}
interface SomeExtension extends SomeInterface {
description: string
content: string
otherFields: Record<string, any>
}
function isSomeExtension (value: SomeInterface): value is SomeExtension {
// Se value contiene description, ÃĻ di tipo SomeExtension.
return "description" in value;
}
function someFunction (obj: SomeInterface): void {
// Stampo tutte le proprietà .
// Validi:
console.log(obj.id);
console.log(obj.title);
if (isSomeExtension(obj)) {
// Validi: obj ÃĻ di tipo SomeExtension in questo punto del codice.
console.log(obj.description);
console.log(obj.content);
console.log(obj.otherFields);
}
}
Tutto corretto!
Un appunto importante in chiusura di questo lungo paragrafo: tenete conto che con i type predicate stiamo praticamente âsaltandoâ i controlli di tipo che TypeScript ci offre, e che il compilatore si fiderà completamente di noi per quanto riguarda il funzionamento della type guard. CiÃē significa che dobbiamo fare molta attenzione a scrivere la funzione che controlla il tipo: se sbagliamo qualche condizione nel body, ce ne renderemo conto solo dai bug a runtime!
Function overload
Chi proviene da linguaggi come Java conoscerà il method overloading: si tratta di quella funzionalità che permette di specificare, allâinterno di una classe o di unâinterfaccia, piÃđ metodi con lo stesso nome e diversi insiemi di parametri – per esempio un numero diverso di parametri, oppure parametri di tipo differente.
Forse non tutti sanno cheâĒ una funzionalità molto simile esiste anche in TypeScript, e puÃē essere davvero utile in certe situazioni. Sto parlando del function overload, utilizzabile sia sulle funzioni che sui metodi di una classe.
Il funzionamento ÃĻ sostanzialmente lo stesso del method overloading, ma con una differenza importante: mentre il method overloading permette di dichiarare effettivamente piÃđ metodi con diversi body, nel function overload lâeffettiva funzione con il body deve essere una sola, ma possono essere specificate diverse signature per le varie versioni. CiÃē significa che la funzione dovrà essere scritta in modo da essere compatibile con tutte le signature, altrimenti avremo un errore di tipo.
// Signature diverse:
function someFunction (param: number): number;
function someFunction (param: string): string;
// Implementazione della funzione:
function someFunction (
param: number | string
): number | string {
if (typeof param === "number") {
console.log("Ã un numero!");
} else {
console.log("Ã una stringa!");
}
return param;
}
const val1 = someFunction(42);
const val2 = someFunction(
"Addio, e grazie per tutto il pesce"
);
Questo vincolo puÃē far sembrare il function overload limitante e di scarsa utilità , ma bisogna tenere conto di un vantaggio importante: dichiarando la funzione in questo modo, TypeScript sarà in grado di assegnare correttamente, a seconda dei parametri in ingresso, il tipo del valore che viene restituito. In altre parole, non dovremo preoccuparci di fare manualmente un ulteriore type narrowing su quello che ci restituisce la nostra funzione.
Per dimostrare le potenzialità di quanto detto, aggiungiamo un paio di righe allo snippet di prima.
// Signature diverse:
function someFunction (param: number): number;
function someFunction (param: string): string;
// Implementazione della funzione:
function someFunction (
param: number | string
): number | string {
if (typeof param === "number") {
console.log("Ã un numero!");
} else {
console.log("Ã una stringa!");
}
return param;
}
const val1 = someFunction(42);
const val2 = someFunction(
"Addio, e grazie per tutto il pesce"
);
// Validi:
console.log(val1.toFixed());
console.log(val2.length);
In questo esempio, trattiamo val1 e val2 rispettivamente come un numero e una stringa. Il motivo per cui possiamo farlo senza ottenere errori ÃĻ proprio il fatto che abbiamo utilizzato il function overload: TypeScript ÃĻ in grado di dire che someFunction restituirà un numero quando riceve un numero e una stringa quando riceve una stringa.
Ecco come sarebbero cambiate le cose se non avessimo usato il function overload.
function someFunction (
param: number | string
): number | string {
if (typeof param === "number") {
console.log("Ã un numero!");
} else {
console.log("Ã una stringa!");
}
return param;
}
const val1 = someFunction(42);
const val2 = someFunction(
"Addio, e grazie per tutto il pesce"
);
// Non validi: TypeScript non ÃĻ in grado
// di dedurre il valore delle variabili.
console.log(val1.toFixed());
console.log(val2.length);
// Dobbiamo fare type narrowing manualmente:
if (typeof val1 === "number") {
console.log(val1.toFixed);
}
if (typeof val2 === "string") {
console.log(val2.length);
}
Già questo semplicissimo esempio ÃĻ diventato molto piÃđ verboso; potete immaginare lâimpatto che puÃē avere la cosa in un codice piÃđ complesso e realistico.
Unâapplicazione molto valida per il function overload ÃĻ quando abbiamo una funzione che puÃē operare indifferentemente su un singolo valore o su un array di valori.
function multiplyValue(
value: number,
multiplyBy: number,
): number;
function multiplyValue(
value: number[],
multiplyBy: number,
): number[];
// Moltiplica un valore o ogni valore
// in un array per un operando.
function multiplyValue(
value: number | number[],
multiplyBy: number,
): number | number[] {
if (Array.isArray(value)) {
const result = [];
for (let i = 0; i < value.length; i++) {
result.push(value[i] * multiplyBy);
}
return result;
} else {
return value * multiplyBy;
}
}
const singleValue = multiplyValue(11, 2);
const arrayOfValues = multiplyValue(
[1, 1, 2, 3, 5],
5
);
// singleValue ÃĻ un number:
console.log(singleValue.toFixed());
// arrayOfValues ÃĻ un array di number:
arrayOfValues.forEach((item) => {
console.log(item.toFixed())
});
(Notare fra lâaltro come anche Array.isArray sia una type guard valida.)
Nessuna type guard necessaria al di fuori della funzione! Esistono forse parole piÃđ dolci di âposso scrivere meno codiceâ?
Conclusione
Anche questo secondo articolo sulle funzionalità di TypeScript ÃĻ finito (non hai ancora letto la prima parte? Che aspetti?!). Spero che questi paragrafi siano riusciti a farvi scoprire qualcosa di nuovo, o magari a farvi soffermare su qualche aspetto del linguaggio che non avevate mai considerato prima.
Di nuovo, se avete qualcuno dei vostri strumenti segreti per TypeScript che vi piacerebbe condividere, non vedo lâora di conoscerli. Uscirà mai un terzo articolo? Chissà !