Questo articolo su TypeScript sarebbe dovuto uscire molto tempo fa, ma abbiamo avuto qualche difficoltà a farlo compilare. Abbiamo finalmente risolto tutti gli errori di tipo, quindi ecco a voi.
TypeScript
Ah, TypeScript. Il linguaggio di programmazione open source sviluppato da Microsoft come estensione di JavaScript ormai non ha più bisogno di presentazioni. Con la crescente popolarità di tecnologie come React, Angular e Node.js, la necessità di sviluppare applicativi JavaScript robusti e facili da mantenere si è fatta sempre più difficile da ignorare, e la tipizzazione statica fornita da TypeScript dà sicuramente una grossa mano per questo.
Se fate parte del sempre più ristretto gruppo di sviluppatori e sviluppatrici web che non hanno ancora adottato TypeScript, non preoccupatevi: per chi conosce JavaScript, il suo cugino staticamente tipato non è difficile da approcciare. Essendo un’estensione, TypeScript può nativamente compilare qualunque applicazione scritta in JavaScript – con l’eccezione di alcuni errori di tipo -, permettendo anche di passare progressivamente alle sue nuove funzionalità.
Lo scopo di questo articolo, però, non è introdurre TypeScript, anche perché ciò sarebbe ridondante per una buona parte di voi, bensì mostrare alcune interessanti funzionalità che ho pian piano scoperto nel corso del mio lavoro con il linguaggio diretto da Anders Hejlsberg. Al di là delle meccaniche più palesi, infatti, TypeScript ha molte possibilità non ovvie anche per chi ha una discreta esperienza con il suo utilizzo, e che possono risultare veramente molto utili per rendere le nostre applicazioni ancora più robuste e leggibili oppure per risparmiare codice non necessario.
Ecco quindi cinque trucchi da usare nei vostri progetti TypeScript, a partire dai più noti e diffusi fino ad arrivare a quelli un po’ più oscuri e situazionali, completi di esempi sul playground ufficiale, tutti garantiti sull’attuale versione del linguaggio (4.8).
Utility type: Omit e Pick
Gli utility type sono tipi speciali disponibili globalmente in qualsiasi applicativo TypeScript. Si tratta di costrutti che, partendo da un tipo, ad esempio un’interfaccia, possono produrne un secondo modificando in qualche maniera il primo.
Esistono molti utility type e sono ben documentati nella guida ufficiale, quindi in questo articolo mi limiterò a illustrare due fra quelli che ho trovato più utili nei nostri progetti.
Omit è un utility type che, preso un tipo A con una serie di proprietà, lo trasforma in un tipo B rimuovendo tutte le proprietà specificate nella definizione.
// Tipo originale: interface TypeA { id: string name: string description: string price: number } // Creo un nuovo tipo dall'originale, escludendo le proprietà description e price: type TypeB = Omit<TypeA, "description" | "price">; // Creo un oggetto del nuovo tipo: const objB: TypeB = { id: "1001", // Valido. name: "Nome", // Valido. description: "Descrizione", // Non valido: questa proprietà non esiste in TypeB. }
Omit è molto utile in situazioni in cui si vogliono costruire oggetti parziali di un certo tipo, ma mantenendo i vincoli imposti dal tipo stesso, come le proprietà obbligatorie. Per questo, può rivelarsi più robusto e preciso di Partial, che invece si limita a rendere opzionali tutte le proprietà, anche se meno immediato.
Altro campo in cui Omit aiuta molto è il riutilizzo del codice. Immaginate questo scenario: nella vostra applicazione, magari in una libreria di terze parti, esiste già un tipo TextInputProps, che è un’interfaccia per le proprietà di un input testuale. Volete creare un nuovo input, identico al primo, ma che gestisce solo numeri e non stringhe. Si potrebbe pensare di estendere TextInputProps per cambiare il tipo di value, ma…
interface TextInputProps { label: string name: string value: string } interface NumberInputProps extends TextInputProps { value: number }
Oh, no! Non possiamo estendere l’interfaccia perché i tipi di value non sono compatibili! E adesso? Dobbiamo per forza replicare l’interfaccia, oppure dividere la prima per separare i campi in comune?
No: Omit viene in nostro soccorso. Invece di estendere TextInputProps, estenderemo una versione trasformata, che ha tutti i campi tranne value. Questo ci permette di ridefinire senza problemi la proprietà, anche senza rispettare i vincoli dell’interfaccia madre. La nuova interfaccia non sarà più un’estensione della prima in termini di programmazione orientata agli oggetti – in altre parole: oggetti di tipo NumberInputProps non saranno anche di tipo TextInputProps -, ma in questo caso la cosa non ci interessa.
interface TextInputProps { label: string name: string value: string } interface NumberInputProps extends Omit<TextInputProps, "value"> { value: number }
Una cosa in meno da fare prima di tornare a casa a giocare alla PlayStation! Woo-ooh!
Come avrete intuito, Pick ha il funzionamento diametralmente opposto, ovvero permette di costruire un secondo tipo prendendo soltanto un sottoinsieme delle proprietà del primo, e si rivela utile allo stesso modo.
// Tipo originale: interface TypeA { id: string name: string description: string price: number } // Creo un nuovo tipo dall'originale, prendendo solo le proprietà id e name: type TypeB = Pick<TypeA, "id" | "name">; // Creo un oggetto del nuovo tipo: const objB: TypeB = { id: "1001", // Valido. name: "Nome", // Valido. description: "Descrizione", // Non valido: questa proprietà non esiste in TypeB. }
(Nota per i so-tutto-io che si stavano già scrocchiando le dita: sì, so che la prima situazione si risolverebbe molto meglio con i generics type, era solo un esempio. Tenete giù le mani dalla tastiera e stasera non vi umilierò in Elden Ring.)
Accedere al tipo di una proprietà
Questa è molto semplice, ma riesce comunque a evitare di replicare alcune informazioni nel codice.
Ecco una situazione abbastanza comune in un’applicazione TypeScript relativamente complessa: immaginate di avere un’interfaccia, con una serie di proprietà ciascuna con il proprio tipo. Dovete creare un oggetto che implementa questa interfaccia, ma uno dei valori è particolarmente complicato da calcolare, e preferite farlo prima in una variabile per poi usare questa variabile come valore. La domanda è: come fare a rendere questa variabile staticamente tipata con il tipo giusto?
La risposta più semplice è banalmente prendere il tipo della relativa proprietà dell’interfaccia e ripeterlo all’inizializzazione della variabile.
// Interfaccia: interface SomeInterface { id: number code: string name: string } // Dichiaro una variabile per calcolare il valore di code: let code: string; // <- Tipo della proprietà ripetuto // Logica di inizializzazione incredibilmente complessa: code = "CODICE"; const someObject: SomeInterface = { id: 1, code: code, name: "Nome", }
Questa replicazione, però, non è necessaria. Quando abbiamo un tipo che ha delle proprietà, come un’interfaccia, TypeScript ci permette di fare riferimento direttamente al tipo di una specifica proprietà usando le parentesi quadre, come se stessimo accedendo a un comune oggetto JavaScript.
// Interfaccia: interface SomeInterface { id: number code: string name: string } // Dichiaro una variabile per calcolare il valore di code: let code: SomeInterface["code"]; // <- Nessuna replicazione! // Logica di inizializzazione incredibilmente complessa: code = "CODICE"; const someObject: SomeInterface = { id: 1, code: code, name: "Nome", }
Solo con le parentesi quadre, però: non provateci con la notazione puntata.
Così facendo, sarà l’interfaccia la sola ad avere l’informazione sul tipo della proprietà, e se quest’ultimo in futuro cambiasse avremmo una cosa in meno da modificare. Intendiamoci: se la proprietà passa da un primo tipo a un secondo non compatibile dovremo in ogni caso correggere l’inizializzazione del valore, ma non avremo da preoccuparci della dichiarazione della variabile.
Allo stesso modo, è possibile accedere al tipo di un elemento di un array usando il tipo del suo indice, ovvero number.
// Interfaccia: interface SomeInterface { id: number code: string name: string } // Dichiaro un array di oggetti di tipo SomeInterface: type SomeInterfaceArray = SomeInterface[]; // Inizializzo una variabile, usando l'array per prendere il tipo: const someObject: SomeInterfaceArray[number] = { id: 1, code: "CODICE", name: "Nome", }
Type narrowing: discriminated union
Una delle mie funzionalità preferite, e una davvero interessante per chi proviene dai classici linguaggi di programmazione orientati agli oggetti.
Tanto per cominciare: non credo che gli union type siano un mistero per nessuno di noi. Si tratta di quella funzionalità che permette di costruire un nuovo tipo combinando tipi esistenti con l’operatore pipe (|).
// Union type: type SomeUnion = string | boolean; // Valido: const someVariable1: SomeUnion = "Stringa"; // Valido: const someVariable2: SomeUnion = false; // Non valido: const someVariable3: SomeUnion = 10;
Altrettanto noto è il fatto che, dato che i valori scalari in TypeScript sono considerati tipi validi, è possibile costruire degli union type che permettano di valorizzare una variabile o una proprietà con uno di una serie di valori imposti dal tipo stesso, ad esempio una fra tre stringhe.
// Union type: type SomeUnion = "stringa_1" | "stringa_2" | "stringa_3"; // Valido: const someVariable1: SomeUnion = "stringa_1"; // Non valido: const someVariable2: SomeUnion = "stringa_5";
Tutto abbastanza noioso fin qui, ma datemi un attimo; ora arriva il bello. Sapevate che è possibile usare gli union type per discriminare fra diversi tipi e fare sì che il compilatore riconosca la tipizzazione statica? Un po’ contorto da spiegare a parole, lo so. Facciamo di nuovo un esempio.
Mettiamo che il nostro progetto TypeScript chiami o esponga un’API REST. La risposta di questa API contiene:
- uno status, SUCCESS o FAIL;
- i dati di risposta data, il cui tipo non è rilevante per l’esempio;
- una stringa error_message, che in caso di fallimento riporta una descrizione dell’errore avvenuto. La risposta conterrà error_message solo se lo status è FAIL.
Come modelliamo questa struttura dati nella nostra applicazione?
Pensando alla tipica tecnica object-oriented, potremmo utilizzare un’interfaccia del genere, con status che può assumere uno dei due valori e error_message sempre presente ma opzionale.
interface ApiResponse { status: "SUCCESS" | "FAILURE" data: any // Verrà popolata solo in caso di status FAILURE. error_message?: string }
Lo svantaggio di questa interfaccia è che non fornisce nessuna validazione statica su error_message. Siamo noi che dobbiamo assicurarci che il campo sia popolato nella giusta situazione, e possiamo accorgerci di eventuali errori solo in fase di debug.
interface ApiResponse { status: "SUCCESS" | "FAILURE" data: any // Verrà popolata solo in caso di status FAILURE. error_message?: string } // Valido: il compilatore non può dedurre che questa situazione è errata. const response: ApiResponse = { status: "SUCCESS", data: {}, error_message: "Messaggio di errore" }
Gli union type di TypeScript, tuttavia, ci mettono a disposizione una potente alternativa per risolvere il problema: la discriminated union, che fa parte della più ampia tematica del type narrowing, ovvero la funzionalità che permette a TypeScript di capire il tipo di una certa variabile in base alla nostra gestione della tipizzazione e al flusso dell’applicazione. Riscriviamo l’esempio precedente in questo modo.
- Invece di una sola interfaccia, ne creiamo due: una per la risposta in caso di successo e una per la risposta in caso di fallimento.
- Nella ApiSuccessResponse, tipizziamo lo status perché possa assumere solo il valore SUCCESS e non includiamo error_message. Viceversa, in ApiFailureResponse lo status può assumere solo il valore FAIL e error_message è una stringa non opzionale.
- Infine, creiamo un terzo tipo ApiResponse che è l’unione delle due interfacce di cui sopra.
Ecco il risultato.
interface ApiSuccessResponse { status: "SUCCESS" data: any } interface ApiFailureResponse { status: "FAIL" data: any error_message: string } type ApiResponse = ApiSuccessResponse | ApiFailureResponse;
Con questa tipizzazione, il compilatore è in grado di discriminare fra i due diversi tipi di risposta, e riuscirà a validare staticamente la presenza e il tipo di error_message, sia in creazione che in lettura.
interface ApiSuccessResponse { status: "SUCCESS" data: any } interface ApiFailureResponse { status: "FAIL" data: any error_message: string } type ApiResponse = ApiSuccessResponse | ApiFailureResponse; // Non valido: in caso di status SUCCESS non può esserci error_message. const response1: ApiResponse = { status: "SUCCESS", data: {}, error_message: "Messaggio di errore", } // Non valido: in caso di status FAIL deve esserci error_message. const response2: ApiResponse = { status: "FAIL", data: {}, } // Validi: const response3: ApiResponse = { status: "SUCCESS", data: {}, } const response4: ApiResponse = { status: "FAIL", data: {}, error_message: "Messaggio di errore", }
Rassicurante, non è vero?
Const assertion (array di scalari come union type)
Per quanto siano comodi gli union type, hanno un difetto: essendo parte della tipizzazione statica, le informazioni sui possibili valori non possono in alcun modo essere utilizzate a runtime. Questo significa, per esempio, che non c’è modo di iterare sui possibili valori di uno union type, né di controllare se un certo valore ottenuto dinamicamente sia valido.
Il modo più pulito per gestire situazioni in cui c’è bisogno di usare una enumerata di valori sia staticamente che a runtime è – poco sorprendentemente – usare una Enum. Le Enum sono tipi che permettono di definire una serie di costanti con un nome, vengono utilizzati dal compilatore per la tipizzazione statica e, a differenza degli union type, esistono anche a runtime.
Un risultato simile, però, si può ottenere anche con un array di scalari, come un array di stringhe. Per farlo, occorre definire l’array con un costrutto chiamato const assertion. Di che cosa si tratta?
Quando una variabile viene dichiarata e inizializzata senza un’esplicita tipizzazione, TypeScript deduce per noi il tipo più adeguato a seconda del valore con cui è stata inizializzata. Un array di stringhe, quindi, riceverà il tipo string[].
const array = ["stringa_1", "stringa_2", "stringa_3"]; // Tipo dedotto: // declare const array: string[];
Questo comportamento può essere controllato con la const assertion, che impone a TypeScript di dedurre il tipo più specifico possibile a partire dal valore. Un array di stringhe inizializzato con una serie di valori, di conseguenza, verrà interpretato come un array in sola lettura, di lunghezza fissa, contenente solo quei valori in quelle specifiche posizioni – una tupla, insomma.
const array = ["stringa_1", "stringa_2", "stringa_3"] as const; // Tipo dedotto: // declare const array: readonly ["stringa_1", "stringa_2", "stringa_3"]
L’array può comunque essere utilizzato in lettura nel codice, per iterare sui valori oppure controllare se un altro valore compare nella lista, ma con questo ulteriore accorgimento può anche essere utilizzato per creare degli union type.
const someArray = ["stringa_1", "stringa_2", "stringa_3"] as const; type SomeUnion = typeof someArray[number]; // Valido: const someVar1: SomeUnion = "stringa_1"; // Non valido: const someVar2: SomeUnion = "stringa_5"; // Posso comunque usare someArray come array: if (someArray.indexOf("stringa_1") !== -1) { console.log("Tipo valido"); } for (let i = 0; i < someArray.length; i++) { console.log(someArray[i]); }
Questo ci permette di combinare i vantaggi dell’avere tutti i valori che ci interessano in un array, molto utile per esempio nella validazione Yup, e dell’avere uno union type per controllare staticamente il valore di una proprietà e avvalerci dell’autocompletamento del nostro IDE.
Tipi union dinamici
Wow, ancora un altro trucco sugli union type! Quante possibilità c’erano?
Come già detto sopra, in molti casi gli union type sono utilizzati per creare variabili o proprietà che possono assumere uno di una serie di valori scalari.
// Union type: type SomeUnion = "stringa_1" | "stringa_2" | "stringa_3"; // Valido: const someVariable1: SomeUnion = "stringa_1"; // Non valido: const someVariable2: SomeUnion = "stringa_5";
Questa funzionalità è comoda, ma anche abbastanza limitante, dato che non possiamo introdurre nessuna variazione in questi valori. Tutto ciò che non rientra precisamente negli scalari specificati nella definizione del tipo verrà rifiutato dal compilatore senza tanti complimenti.
Ora, mettiamo di avere una situazione in cui una stringa può assumere una serie di valori simili, ma non completamente statici. Per esempio, abbiamo un algoritmo che fa diversi tentativi per completare un task che potrebbe fallire, e vogliamo tenere una variabile che rappresenta lo stato di questo algoritmo.
Creiamo quindi una variabile status, che può valere:
- none per quando nessun tentativo è stato effettuato;
- success se il task termina con successo;
- fail se il task termina con un errore;
- attempt_N dove N è il numero del tentativo in corso (attempt_1, attempt_2, attempt_3…).
Per come siamo abituati a usare gli union type, il meglio che potremmo fare è riportare i valori fissi e lasciare un generico string per l’ultimo…
// Union type per lo status: type StatusType = "none" | "success" | "fail" | string; // Questo è valido... const status1: StatusType = "none"; const status2: StatusType = "attempt_3"; // ...ma anche questo è valido. const status3: StatusType = "Ehi, ma questa stringa non contiene uno stato!";
…ma non è l’unica possibilità. Le stringhe con cui formiamo gli union type possono infatti avere parti dinamiche, che possiamo gestire semplicemente concatenando il tipo della parte dinamica alla parte fissa con le template string.
// Union type per lo status: type StatusType = "none" | "success" | "fail" | `attempt_${number}`; // Questi sono validi... const status1: StatusType = "none"; const status2: StatusType = "attempt_3"; // ...e questo no. const status3: StatusType = "Oh :(";
Vi vogliamo bene, union type <3
Conclusione
Approcciare TypeScript è un’esperienza stimolante per chi proviene da altri linguaggi di programmazione, soprattutto quelli orientati agli oggetti. La sua natura di estensione di un linguaggio non staticamente tipato come JavaScript ha permesso al team di sviluppo di trovare svariate soluzioni a problemi più o meno comuni, e anche dopo diverso tempo capita di scoprirne qualcuna nuova di tanto in tanto.
Vi ho presentato alcune delle funzionalità che ho trovato più interessanti nel corso del mio lavoro, e sono certo che ne scoprirò ancora altre man mano che vado avanti.
Voi che ne pensate? Conoscevate tutti i trucchi presentati nell’articolo, o sono riuscito a farvi scoprire qualcosa di nuovo? Ne conoscete altri che meritano di essere mostrati al mondo? Fatemelo sapere, magari un giorno o l’altro ci sarà un sequel!
Foto di Pakata Goh su Unsplash