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à!
Foto di Christopher Gower su Unsplash