Guida Typescript in italiano | Aulab

GUIDE PER ASPIRANTI PROGRAMMATORI

Guida Typescript in italiano

Immagine di copertina

Vuoi avviare una nuova carriera o fare un upgrade?

Trova il corso Digital & Tech più adatto a te nel nostro catalogo!

1

Introduzione a Typescript

1.1

Che cos'è TypeScript

Avvicinandoti al mondo dello sviluppo web, avrai sicuramente sentito parlare di questo linguaggio di programmazione; la sua popolarità è andata crescendo negli anni e, ad oggi, non esiste framework o libreria JavaScript che non metta anche a disposizione le opportune definizioni di tipo per integrarsi al meglio in una codebase TypeScript.  Ma che cos'è esattamente TypeScript?  TypeScript è un linguaggio di programmazione open source basato sul linguaggio JavaScript e sviluppato da Microsoft.  Curiosità: il creatore di TypeScript, Anders Hejlsberg, è anche il creatore di C#, Turbo Pascal e Delphi; un vero e proprio guru dei linguaggi di programmazione!  In questa guida vedremo nel dettaglio come usare TypeScript per scalare al meglio i nostri progetti web, grazie alle enormi potenzialità che questo linguaggio mette a disposizione per organizzare il codice, facilitare lo sviluppo e la descrizione dei tipi di dato e prevenire intere categorie di errori e problematiche prima ancora che le nostre applicazioni web vengano eseguite! 
1.2

TypeScript vs JavaScript

TypeScript è un superset di JavaScript, vale a dire che qualsiasi codice JavaScript è anche codice TypeScript valido. Questa è un'ottima notizia, perché significa che per iniziare a usare TypeScript in un progetto JavaScript, potremo riciclare il tutto il codice senza apportare alcuna variazione!  Ma allora, in che modo TypeScript aggiunge funzionalità a JavaScript?  Essenzialmente, TypeScript aggiunge al linguaggio JavaScript principalmente due tipologie di sintassi, strettamente collegate tra loro: le annotazioni e le dichiarazioni di tipo. Le vedremo più avanti nel dettaglio.  Intanto, vediamo che aspetto ha un breve blocco di codice in TypeScript:    // dichiarazione di tipo type State = "active" | "idle"; // variabile dichiarata con annotazione di tipo let currentState: State = "idle"; // funzione dichiarata con annotazioni di tipo function setState(newState: State): void { currentState = newState; } setState("active"); Da questo assaggio risulta evidente come, a meno della dichiarazione introdotta dalla parola chiave type e delle annotazioni dopo i due punti, quello rappresentato è semplice codice JavaScript. Più avanti vedremo nel dettaglio come queste aggiunte al codice possono aiutarci a formalizzare il dominio in cui la nostra applicazione opera, in modo che diventi pressoché impossibile scrivere del codice che porti a situazioni prive di senso. 
1.3

Perché usare TypeScript

Il linguaggio JavaScript è un linguaggio di programmazione molto popolare, versatile e potente; tuttavia, mantenere una codebase JavaScript di complessità crescente, comporta una sfida non da poco:  la maggior parte degli errori potrà essere riscontrata solo dopo aver lanciato la nostra web app, a causa del type system dinamico di questo linguaggio;  sarà sempre più difficile capire in che modo è possibile accedere correttamente a un certo dato, se non si sa esattamente da dove viene e che trasformazioni ha subito.  Prendiamo ad esempio il seguente codice:  function sum(a, b) { return a + b; } const result = sum(false, "hello"); console.log(result); // -> "falsehello" La funzione sum è, probabilmente, pensata per sommare due numeri, eppure JavaScript processerà la somma senza protestare; in questo esempio (e in un'infinità di situazioni analoghe) il nostro codice JavaScript gestisce con apparente successo casistiche che, normalmente, si verificano solo quando il codice è progettato male, oppure al presentarsi di casi limite non gestiti correttamente.  Vediamo un altro esempio:  const model = null; const message = { }; console.log(model.name); // -> TypeError: Cannot read properties of null (reading 'name') console.log(message()); // -> TypeError: message is not a function Nell'esempio, le due variabili hanno volutamente nomi fuorvianti per sottolineare un problema al quale siamo costantemente esposti lavorando con linguaggi dinamici come JavaScript: la responsabilità di definire, processare e accedere alle variabili in modo consistente è totalmente in carico al programmatore.  In questo caso, abbiamo introdotto dei bug semplicemente andando a leggere una proprietà su una variabile con valore null, oppure tentando di chiamare una variabile che non è una funzione; JavaScript ha, giustamente, segnalato che questi sono errori di tipo, ma ha potuto farlo solo in esecuzione, quindi troppo tardi.  Ovviamente, negli esempi presentati qui sopra, l'occhio del programmatore è una risorsa sufficiente ad individuare e risolvere il problema. Tuttavia, man mano che il nostro progetto cresce (e verosimilmente può crescere molto più di così), sarà sempre più difficile prevedere quale sarà il ciclo di vita di una variabile e in quali punti del codice verrà processata.  Ma quindi, allora, che senso ha usare JavaScript?   JavaScript non ha niente che non vada di per sé, ma quando lavoriamo con questo linguaggio dovremo fare particolare attenzione a molti errori che sono riscontrabili solo testando l'applicazione; d'altra parte, se un errore può essere riscontrato prima che la nostra web app vada in esecuzione, TypeScript farà di tutto per aiutarci a prevenirlo.  In definitiva, TypeScript è nato per soddisfare le esigenze di scalabilità dei progetti JavaScript, mettendo a disposizione (come il nome stesso suggerisce) un potente sistema di tipi, validato a compile time, che ci consentirà di dichiarare quali tipi di dato stiamo processando e si assicurerà che ci sia sempre consistenza fra questi.  Consideriamo anche che al crescere di un progetto, cresce anche il numero di contributori; un type system rigoroso ci permetterà anche di documentare in modo formalmente rigoroso le API del nostro codice. Non è un caso che moltissime librerie scritte in semplice JavaScript forniscano comunque tutte le definizioni di tipo in TypeScript, in modo da fissare il corretto utilizzo delle variabili e delle funzioni messe a disposizione.  Note su questa guida  Essendo un superset di JavaScript, TypeScript in sé è un linguaggio molto semplice da approcciare, e può essere utilizzato a diversi livelli di complessità, in base all'esperienza e alla conoscenza del suo sistema di tipi.  Riguardo al sistema di tipi, bisogna fare una premessa importante per affrontare questa guida: il type system di TypeScript è molto complesso.  Ovviamente, la sua complessità è tutt'altro che superflua e, anzi, per certi versi, offre una versatilità senza pari tra i linguaggi di programmazione.  Tuttavia, questa complessità rende pressoché impossibile procedere in modo perfettamente lineare.  In questa guida incontrai spesso espressioni del tipo "come abbiamo visto" o "più avanti approfondiremo": questo perchè riteniamo più opportuno introdurre concetti utili ed esemplificativi appena ne abbiamo l'occasione, con la promessa di approfondirli in modo più strutturato a tempo debito; l'alternativa sarebbe procedere in modo più lineare, ma sacrificando notevolmente la ricchezza di esempi e casi d'uso per i costrutti che incontreremo: sarebbe un peccato, no? 

2

Come usare Typescript

2.1

TypeScript playground

Il modo più rapido per provare TypeScript è il playground disponibile sul sito ufficiale; qui, avremo la possibilità di scrivere del codice TypeScript e visualizzare in tempo reale il compilato JavaScript, oppure di eseguirlo e consultare l'output in console.  Che cos'è Typescript Playground TypeScript Playground è uno strumento online fornito ufficialmente da Microsoft, progettato per consentire agli sviluppatori di sperimentare con il linguaggio TypeScript direttamente dal browser. Gli utenti possono scrivere codice TypeScript nel lato sinistro dell'interfaccia e vedere il corrispondente codice JavaScript generato in tempo reale sul lato destro. Inoltre, è possibile eseguire il codice TypeScript direttamente nel browser e visualizzarne l'output nella console. Questo strumento è particolarmente utile per esplorare le caratteristiche di TypeScript, testare piccoli frammenti di codice e comprendere come il codice TypeScript si traduce in JavaScript. Il TypeScript Playground offre un ambiente di sviluppo leggero e immediato, senza la necessità di configurare un ambiente locale, rendendo più semplice per gli sviluppatori comprendere e familiarizzare con il linguaggio TypeScript. Ovviamente, questa soluzione va più che bene per dare un'occhiata veloce alle caratteristiche di questo linguaggio ma, se vogliamo davvero iniziare a sfruttarlo nei nostri progetti, la soluzione più pratica è quella di attrezzarci per lo sviluppo in locale.  In alternativa, se non vogliamo configurare un ambiente di sviluppo locale, possiamo utilizzare un IDE online come stackblitz. In questa guida utilizzeremo il compilatore TypeScript (tsc) insieme a Visual Studio Code, che integra nativamente il supporto a questo linguaggio. 
2.2

Come Installare il compilatore Typescript

Per usare TypeScript per lo sviluppo in locale, dobbiamo anzitutto installare il suo compilatore. Il compilatore svolge i seguenti compiti:   Convalidare formalmente la consistenza dei dati nel programma (static type checking)  Collegare i vari file che compongono la codebase (module linking o bundling)  Produrre il codice JavaScript che verrà effettivamente eseguito (compiling)  Più precisamente il compilatore di TypeScript è detto transpiler, dal momento che non produce codice binario (o codice macchina), ma si limita a tradurre da un linguaggio di programmazione a un altro.  In questa guida lo installeremo globalmente, in modo da poterlo lanciare da riga di comando. Anzitutto assicuriamoci di avere installato Node sulla nostra macchina:  node -v Se il comando ci restituisce la versione di Node (ad esempio, v18.13.0), possiamo procedere, altrimenti andiamo sul sito di Node e seguiamo le istruzioni per installarlo.  Insieme a Node dovremmo avere installato NPM (Node Package Manager), possiamo controllare di averlo digitando in console: npm -v Notare che Node e NPM non seguono lo stesso versionamento, quindi non spaventiamoci se i due numeri di versione non corrispondono!  Se NPM non dovesse risultare installato, possiamo seguire le istruzioni presenti sulla pagina GitHub del progetto.  A questo punto, dovremmo essere pronti, quindi possiamo lanciare:  npm install -g typescript Da questo momento in poi, ci basterà lanciare tsc su un file con estensione .ts per avviarne la compilazione e ottenere un file JavaScript che potremo testare in Node oppure linkandolo come script in una pagina web. Più avanti in questa guida vedremo come farlo.  Per completezza, va detto che esistono interpreti TypeScript che non richiedono una fase di compilazione; il più famoso è Deno, un'alternativa a Node. In questa guida non li prendiamo in considerazione perché sono ancora tecnologie relativamente sperimentali e non è a quelli che ci si riferisce quando si pensa normalmente a progetti sviluppati in TypeScript, che sono invece compilati a JavaScript.  Un altro lavoro che il compilatore svolge per noi è quello di uniformare il compilato JavaScript; infatti, è possibile specificare quale versione di JavaScript vogliamo come target di compilazione, in base a quanto vogliamo andare indietro con la retrocompatiblità. In questo modo, ai benefici di TypeScript già elencati, si aggiunge la possibilità di supportare sintassi più moderne anche su browser obsoleti. 
2.3

Crea il tuo primo “programma” in Typescript: Hello world!

Ora che abbiamo il compilatore, non ci resta che provarlo creando il nostro primo "programma" TypeScript, e per farlo realizzeremo, ovviamente, un Hello world.   Creiamo un file main.ts con il seguente codice:  console.log("Hello world!"); Ehi, ma questo è normalissimo linguaggio JavaScript!  Esatto: siccome non abbiamo dichiarato variabili né definito funzioni o classi, non c'è nessun bisogno di usare dichiarazioni o annotazioni di tipo, motivo per cui qui il codice TypeScript coincide al 100% con il suo compilato.  Poiché questo file .ts contiene codice JavaScript, volendo, possiamo lanciarlo in Node, nonostante la sua estensione sia .ts anziché .js:  node main.ts # -> Hello world! Il comando gira senza problemi, ma non abbiamo fatto molta strada. Vediamo, dunque, di far girare il compilatore almeno una volta:  tsc main.ts Ora, accanto al file main.ts è apparso un nuovo file main.js, dal contenuto identico. Anche questo può essere lanciato in Node, oppure essere linkato come script in una pagina HTML. Per semplicità, in questa guida ci limiteremo a lanciare i nostri script in Node.  Iniziamo a vedere un assaggio di come possiamo usare TypeScript per annotare i tipi coinvolti; sostituiamo il precedente contenuto di main.ts con il seguente codice:  function log(message: string): void { console.log(message); } var greeting: string = "Hello world!"; log(greeting); Vediamo rapidamente cosa dice il nostro codice TypeScript:  anzitutto, definisce una funzione log che accetta un parametro message di tipo string e restituisce void (cioè assolutamente niente);  poi, dichiara una variabile greeting di tipo string e vi assegna immediatamente il valore "Hello world!";  infine, chiama la funzione log passandole greeting come argomento.  Rilanciando il compilatore, noteremo che ora il compilato ha questo aspetto:  function log(message) { console.log(message); } var greeting = "Hello world!"; log(greeting); In altre parole, tutte le annotazioni di tipo (e le righe vuote) sono semplicemente scomparse, lasciando il codice JavaScript funzionante.  Quindi, dov’è l'utilità?  Te lo spieghiamo subito. Se ora provassimo a chiamare la funzione log passando un argomento numerico (ad esempio: log(1)), noteremmo sia in Visual Studio Code, sia lanciando tsc, che ci viene segnalato un errore:  Argument of type 'number' is not assignable to parameter of type 'string'. Il compilatore ci sta dicendo che l'argomento 1 non è compatibile con il tipo string del parametro message richiesto da log. Questo perché nella definizione di log abbiamo vincolato il tipo del suo parametro a string.  E se volessimo fare in modo che log accetti parametri di più di un tipo?  Più avanti tutto sarà rivelato.  Piccolo distinguo non fondamentale, ma è una sfumatura interessante: il parametro è la variabile che la funzione definisce nella propria firma, mentre l'argomento è il valore che le viene passato al momento della chiamata.  Nell'esempio di sopra, il contenuto di greeting è l'argomento, mentre message è il parametro.  Nell'esempio abbiamo usato var al posto di const intenzionalmente. Se avessimo dichiarato la variabile usando const, TypeScript l'avrebbe tradotta comunque in var; questo è dovuto al fatto che in qualità di transpiler, tsc deve stabilire una versione di JavaScript a cui compilare, e la versione di default è es3 (ECMAScript 3), che è anche la più vecchia disponibile. Se volessimo compilare all'ultima versione di JavaScript, potremmo sfruttare il parametro --target con versione esnext: tsc --target esnext main.ts Rivelazione: il vero nome di JavaScript è ECMAScript, o meglio: JavaScript è l'implementazione più famosa di ECMAScript, che per la precisione è la specifica tecnica di JavaScript e altri linguaggi di scripting analoghi come JScript e ActionScript. 
2.4

Come creare un nuovo progetto TypeScript vuoto

Abbiamo visto come compilare il nostro codice TypeScript per ottenere codice JavaScript funzionante; ora vedremo come creare un progetto TypeScript vuoto per specificare delle opzioni di compilazione valide in una data cartella e compilare automaticamente i file .ts in essa.  Il compilatore TypeScript offre un semplice comando da lanciare in una cartella di lavoro per inizializzare un progetto TypeScript: tsc --init Questo comando non fa altro che creare un file tsconfig.json, un semplice file JSON dove potremo configurare le varie opzioni che vogliamo far valere nel nostro progetto TypeScript.   Ecco un esempio di tsconfig.json minimale, ripulito di tutti i commenti: { "compilerOptions": { "target": "es2016", "module": "commonjs", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true } } Ci sono moltissime opzioni come queste e sono tutte elencate qui; ci permetteranno, tra le altre cose, di impostare:  il target di compilazione (cioè la versione ECMAScript risultante),  il livello di rigore (strictness) da applicare sul type checking,  il tipo di modularizzazione del codice (sul discorso moduli ci torneremo più avanti).  Vediamo come possiamo sfruttare tsc per lavorare in modo più fluido in un progetto TypeScript semplice. Anzitutto, lanciando tsc da una cartella in cui è presente un file tsconfig.json, non sarà più necessario specificare il/i file TypeScript da compilare: se non specificato diversamente, verranno compilati automaticamente tutti i file .ts presenti nella cartella (e sottocartelle).  Ma possiamo fare di meglio.  Una volta creata una cartella e inizializzato un nuovo progetto TypeScript, lanciamo: tsc -w Questo comando metterà tsc in watch mode, cioè in osservazione sulla cartella e sui file in essa contenuti; ad ogni modifica, il compilatore aggiornerà automaticamente il compilato, e noi potremo consultarlo e lanciarlo con Node più comodamente. 
2.5

Uso di TypeScript nei framework

Come abbiamo visto, possiamo usare tsc per compilare il nostro codice TypeScript generando uno script eseguibile con Node o come script linkato in una pagina HTML. Tuttavia, questo non è il modo più comune per utilizzare TypeScript in un progetto reale. Ad oggi, la maggior parte delle web app sono create a partire da un framework. Un framework è un insieme di scelte tecniche e architetturali implementate al posto nostro per predisporre i compiti più comuni che uno sviluppatore deve assolvere quando sviluppa un'applicazione; al contempo, un framework stabilisce una sorta di standard, di forma mentis, su come approcciare tali scelte architetturali e tali compiti comuni.  Ebbene praticamente tutti i framework web popolari al momento  o offrono un template (o seed) di progetto in TypeScript,  o sono interamente scritti in TypeScript.  Di seguito una lista di comandi che iniziano un progetto TypeScript in diversi framework web: # Angular è scritto in TypeScript # ogni progetto Angular è un progetto TypeScript npm install -g @angular/cli ng new my-app # 'Create React app' offre un seed in TypeScript per lavorare con React npx create-react-app my-app --template typescript # 'Create Next app' offre un seed in TypeScript per lavorare con Next # il progetto è in TypeScript di default, ma è possibile lavorare anche in JavaScript npx create-next-app@latest # 'Create vue' ci chiederà interattivamente se vogliamo lavorare in TypeScript npm create vue@latest # Qwik è scritto in TypeScript e Rust # ogni progetto Qwik è un progetto TypeScript npm create qwik@latest # 'Fastify cli' offre un seed in TypeScript npm install fastify-cli --global fastify generate my-project --lang=ts # Express non offre un seed ufficiale in TypeScript # ma ce ne sono diversi offerti dalla community, eccone uno npx express-generator-typescript my-project In definitiva: in base al framework che vogliamo utilizzare, non dovremo fare altro che selezionare un template basato su TypeScript, tenendo conto che alcuni framework (soprattutto tra quelli più giovani) sono nativamente scritti in TypeScript.  Per rimanere nel perimetro di pertinenza di questa guida, nei prossimi esempi non useremo framework; ad ogni modo, tutto quello che affronteremo potrà (e idealmente dovrà) essere messo in pratica su qualsiasi codebase, indipendentemente dal framework o dalle librerie impiegate.

3

Introduzione ai tipi in Typescript

3.1

Sistema di tipi in Typescript

Ora che abbiamo visto come lavorare con TypeScript, iniziamo a farci un'idea su come sfruttarne le potenzialità.  Come suggerisce il nome, la principale feature che TypeScript aggiunge al linguaggio JavaScript consta di un potente e versatile sistema di tipi (in inglese: type system) che ci permette di identificare tutti i tipi di dato (di seguito tipi) nativi di JavaScript e di definire un'incredibile varietà di tipi personalizzati.  Esistono molti linguaggi di programmazione tipizzati con diversi approcci alla gestione dei tipi di dato, perciò vediamo di collocare TypeScript nel panorama fornendo una classificazione super sintetica, ma densa di significato:  TypeScript presenta un type system statico, strutturale, multi-paradigma.   Type system statico Per type system statico si intende che il tipo di una variabile è noto in compilazione; al contrario, in un linguaggio di programmazione dinamicamente tipizzato (come JavaScript) il tipo di una variabile è noto solo in esecuzione. Il vantaggio di un type system statico consiste nel poter verificare che a una variabile sia sempre assegnato un valore di un tipo compatibile con quello che ci si aspetta quando si andrà a leggere quella variabile.  Il bello è che questo tipo di verifica può essere sempre fatto prima che la nostra web app sia eseguita! Questa caratteristica si chiama compile time type checking, cioè verifica di tipo in fase di compilazione.  Type system strutturale Per type system strutturale si intende che due tipi che esprimono strutture compatibili, sono considerati come lo stesso tipo anche se sono identificati da nomi diversi; al contrario, un type system nominale identifica i tipi esclusivamente in base al loro nome e determina la struttura di un tipo a partire dal nome anziché dalle sue proprietà.  L'adozione di un sistema di tipi strutturale è anche detta duck typing, secondo il principio per cui se un uccello cammina come un'anatra, nuota come un'anatra e starnazza come un'anatra, dev'essere un'anatra!  Type system multi-paradigma Per type system multi-paradigma intendiamo semplicemente che TypeScript offre diversi approcci alla definizione di tipi; per esempio, permette di definire un nuovo tipo combinando o trasformando altri tipi, come spesso si fa in programmazione funzionale, oppure di definire gerarchie di tipi che vanno da quelli più astratti a quelli più concreti sfruttando il polimorfismo, come accade nella programmazione orientata agli oggetti.  Nell'ultima parte di questa guida affronteremo le principali tecniche di applicazione di questi paradigmi e come queste sfruttano il type system di TypeScript. 
3.2

Type checking in Typescript

Come abbiamo detto, il senso di avere un type system statico come quello di TypeScript si compie nel momento in cui è possibile fare una verifica in fase di compilazione (a compile time) della consistenza dei tipi dichiarati e annotati nel nostro codice; è qui che interviene il type checker.   Che cos’è il Type checker in Typescript Il type checker in TypeScript è un particolare componente del compilatore, che si occupa di analizzare il codice sorgente per garantire che le operazioni siano conformi ai tipi definiti nel programma. Il compito del type checker è, essenzialmente, quello di far rispettare i contratti stabiliti dalla tipizzazione di variabili, funzioni, eccetera. In tsconfig.json è possibile stabilire se il compilatore dovrebbe procedere a produrre il compilato JavaScript anche in presenza di errori sollevati dal type checker; in ogni caso, se ci sono errori di tipo, lo sapremo prima che il nostro codice sia eseguito, e potremo decidere se chiudere un occhio o meno.   Type checker di Typescript VS Javascript  Confrontiamo brevemente le caratteristiche introdotte dal type checker di TypeScript rispetto al normale funzionamento di JavaScript:  in TypeScript una variabile, indipendentemente dal percorso che segue, non potrà mai vedersi assegnato un valore incompatibile con il tipo annotato in fase di dichiarazione; in JavaScript è possibile assegnare ad una variabile qualunque valore indipendentemente dal valore precedente; in TypeScript è possibile fissare il tipo degli elementi di un array, mentre in JavaScript gli array sono semplicemente elenchi di dati il cui tipo non può essere fissato; in TypeScript è possibile fissare la struttura di un oggetto ed essere assistiti nell'uso di tale oggetto dal nostro IDE; in JavaScript ogni oggetto è libero di avere qualunque property che abbia un nome valido, quindi nessun IDE può conoscere le property assegnate; in TypeScript è possibile sapere in anticipo il tipo del valore di ritorno di una funzione, quindi se questo dovesse cambiare ne saremo immediatamente informati; in JavaScript, sarà necessario scrivere dei test o dei controlli nel nostro codice per assicurarci di non introdurre errori di tipo quando modifichiamo una funzione; in TypeScript è possibile fissare il tipo di un parametro voluto da una funzione, sicché non sarà necessario scrivere del codice atto a verificare che i valori in ingresso abbiano senso; in JavaScript, prima di iniziare a implementare il corpo della nostra funzione, saremo quasi sempre costretti a scatenare degli errori nel caso in cui il valore introdotto sia di un tipo non sia supportato.    Ora che abbiamo chiari gli scopi del type checker, possiamo iniziare a tipizzare il nostro codice.
3.3

Annotazioni e dichiarazioni in Typescript

Affinché il type checker possa fare il suo lavoro, dovremo combinare in modo rigorosamente consistente le dichiarazioni di tipo e le annotazioni di tipo nel nostro codice. Poiché TypeScript è, essenzialmente, linguaggio JavaScript più qualcosa, la parte di codice che effettua calcoli e operazioni la conosciamo già (hai bisogno di una rispolveratina? Tranquillo: ecco a te la nostra guida Javascript in italiano), e non subisce variazioni.  Si aggiungeranno, però, al nostro codice nuove parole chiave e sintassi che attengono esclusivamente al mondo dei tipi; vediamo qualche esempio:  // 'type' introduce un alias per il tipo 'number' type State = number; // 'interface' definisce un'interfaccia per classi e oggetti interface Stack<T> { push(item: T): void; pop(): T } // 'enum' definisce un elenco finito di valori possibili enum RGBColor { Red, Green, Blue } // 'implements' attribuisce a una classe le proprietà // descritte da un'interfaccia (da implementare) class NumberArrayStack implements Stack<number> { ... } // i due punti assegnano un tipo a funzioni, variabili, ecc const modelId: number = 42; // il tipo identificato dai due punti può anche non avere un nome function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 { ... } L'elenco completo delle sintassi TypeScript è vastissimo e in continua evoluzione, ed è ottimamente documentato dal manuale TypeScript ufficiale. In questa guida vedremo la maggior parte di esse, e ci soffermeremo su come e quando utilizzarle per trarne il meglio in ogni occasione.  Per il momento, possiamo fissare la seguente distinzione:  le dichiarazioni (o definizioni) di tipo sono quelle istruzioni in cui diamo un nome e una descrizione ai tipi che intendiamo utilizzare nella codebase;  le annotazioni di tipo sono tutte quelle note aggiunte al codice JavaScript introdotte dai due punti, dove riportiamo il tipo atteso per un dato elemento del codice.  Come abbiamo detto, il type checker verificherà che i dati che vengono passati nel nostro codice (ad esempio valori assegnati a variabili o passati a funzioni o restituiti da espressioni) siano compatibili con le annotazioni che abbiamo scritto per quei dati.  TypeScript contempla anche l'assenza di annotazioni di tipo, ed è in grado di assegnare implicitamente un tipo adeguato in base all'uso e al contesto di una variabile o funzione. Questa qualità è detta type inference, cioè deduzione (o inferenza) di tipo, ed è una caratteristica che favorisce la transizione graduale da JavaScript a TypeScript, oltre a privilegiare la sintesi quando il tipo di una variabile o il valore di ritorno di una funzione è deducibile dal corpo.  Di seguito qualche esempio di type inference (nei commenti le definizioni con tipo assegnato da TypeScript):  let num = 1; // num: number let greeting = "hello"; // greeting: string function doNothing() {} // doNothing(): void function getNumber() { return 4; } // getNumber(): number Finora abbiamo introdotto concetti ed esempi di tipizzazione senza esserci realmente addentrati nel type system. Questo è stato fatto volontariamente per iniziare a familiarizzare con la sintassi e introdurre alcuni concetti fondamentali, ma in questo momento non è importante che sia tutto chiaro al 100%. Intanto, abbiamo da subito qualche strumento per iniziare a giocare con TypeScript in Visual Studio Code, e man mano che andremo avanti, tutto sarà più chiaro.
3.4

Conversioni, asserzioni e restringimento in Typescript

Conversione di tipo in Typescript In diversi linguaggi di programmazione, il concetto di tipo si accompagna al concetto di type casting, cioè di conversione di tipo. Senza scomodare altri linguaggi, vediamo il seguente codice JavaScript: const one = Number("1"); Abbiamo il valore letterale "1" che rappresenta una stringa, convertito in numero dalla funzione Number e conservato nella costante one. In altre parole, abbiamo effettuato una conversione di tipo. Se tale conversione non fosse stata possibile, Number avrebbe restituito NaN (not a number, che ironicamente è un valore di tipo number). Lo stesso tipo di conversione in JavaScript può essere effettuato analogamente con Boolean e String.  In questo modo possiamo effettuare la conversione tra tipi semplici in JavaScript, e TypeScript sarà sempre allineato sui tipi in uso.  Immaginiamo ora di avere un'informazione di tipo che TypeScript non può in alcun modo inferire:  const data = JSON.parse('{ "name": "John" }'); // const data: any console.log(data.name); In questo caso, l'argomento di JSON.parse è semplicemente una stringa, e potenzialmente questa stringa potrebbe venire da un punto esterno al nostro codice, come un file, un database o una risposta HTTP; quindi, TypeScript non può dedurre che data ha una proprietà name di tipo string!  Come vedremo più avanti, any è un tipo speciale che rappresenta qualunque tipo, ed è usato appositamente per sopprimere gli avvertimenti del type checker sull'uso di una certa variabile, motivo per cui qui TypeScript non batte ciglio alla vista di data.name. Vedremo anche come è possibile rendere TypeScript meno "ingenuo" di fronte ai valori di tipo sconosciuto.  Quindi, come facciamo a tipizzare data?  Vediamolo.   Asserzione di tipo in Typescript Ricollegandoci all’esempio precedente, per tipizzare data ci viene incontro la keyword as, che introduce un'asserzione di tipo (in inglese: type assertion). Usando as stiamo dicendo al type checker: "quest'informazione te la do io, e ti puoi fidare".  Vediamolo in pratica:  const data = JSON.parse('{ "name": "John" }') as { name: string }; Ora che abbiamo istruito TypeScript su come qualificare data, se digitiamo data. nell'IDE, ci verrà automaticamente suggerito di navigare verso la proprietà name.  Vediamo ora il seguente esempio:  const a = 1; // const a: number const b = a as string; // Conversion of type 'number' to type 'string' may be a mistake ... In questo caso, TypeScript ci sta segnalando che la nostra type assertion è priva di senso e probabilmente errata, perché in base alle informazioni disponibili sul tipo di a, non è possibile asserire che b sia una stringa. Dunque TypeScript ammette asserzioni solo laddove si vada ad allargare oppure a restringere la specificità di un tipo.  In pratica, qui il problema è che il tipo string non è un sottoinsieme del tipo number né viceversa, quindi non ha senso interpretare un numero come una stringa; avrebbe invece senso convertire un numero in una stringa:  const a = 1; // const a: number const b = String(a); // const b: string Prendiamo, invece, la seguente funzione:  function concat(a: string | number, b: string | number): string { return a + b; // Operator '+' cannot be applied to types 'string | number' and 'string | number'. } Qui TypeScript, giustamente, protesta perché l'operatore + ha due comportamenti diversi se applicato a numeri o a stringhe. In questo caso, noi potremmo asserire che a e b siano stringhe, ma finiremmo con l'introdurre un bug, perché se questi fossero numeri, il risultato della funzione non sarebbe la loro concatenazione, ma la loro somma algebrica.  Dunque, la cosa migliore sarebbe operare una conversione a stringa, e ci basterà farlo su a perché è sufficiente che uno dei due operandi sia stringa per determinare il comportamento dell'operatore + function concat(a: string | number, b: string | number): string { return String(a) + b; } A questo punto, per TypeScript è tutto a posto: un numero può sempre essere convertito in una stringa, e l'operatore + applicato dopo una stringa eseguirà in automatico un'eventuale conversione a stringa anche per b, restituendo la concatenazione anziché la somma algebrica.  Ma immaginiamo di voler effettuare la conversione solo se necessario; significa che, nel caso in cui a fosse già una stringa, potremmo procedere direttamente alla "somma" tra stringhe:  function concat(a: string | number, b: string | number): string { if (typeof a === "string") return a + b; // a: string return String(a) + b; } In questo caso TypeScript non protesterà, perché il type checker è in grado di intuire che a è una stringa nel primo caso, e un numero nel secondo.  La capacità di TypeScript di restringere il tipo di una variabile tra più tipi possibili in base alle condizioni verificate dal codice è detta restringimento di tipo, in inglese type narrowing.    Restringimento di tipo in Typescript Esistono molti modi in cui TypeScript può restringere il tipo di una variabile, vediamone alcuni:  function f(a: number | string | number[] | null) { // valorizzazione/non-nullità if (a) ... // a: number | string | number[] // typeof if (typeof a === "number") ... // a: number if (typeof a === "object") ... // a: number[] | null // instanceof if (a instanceof Array) ... // a: number[] // uguaglianza if (a === null) ... // a: null } In pratica, invece di prenderci noi la responsabilità di fare un'asserzione, faremo delle verifiche di tipo a runtime, come se stessimo lavorando in JavaScript puro: TypeScript a quel punto saprà con assoluta certezza che i tipi possibili di quella variabile saranno ristretti in base alla condizione verificata.  Abbiamo dunque visto come muoverci tra diversi tipi, ma abbiamo appena grattato la superficie; prima di addentrarci nel dettaglio del type system, vediamo come TypeScript ci aiuta non solo a validare il codice, ma anche e soprattutto a scriverlo. 
3.5

Integrazione di Typescript con IDE

Finora abbiamo imparato che, grazie alle dichiarazioni e alle annotazioni di tipo, TypeScript fornisce un potente sistema di prevenzione contro errori in esecuzione (a runtime).  Un altro motivo per cui questo linguaggio ha visto un enorme successo tra gli sviluppatori sta nel fatto che TypeScript costituisce anche un potente alleato nella consultazione e scrittura del codice, dal momento che può condividere con il nostro editor tutte le informazioni di tipo che ha a disposizione, aiutandolo a suggerirci codice che abbia senso e a segnalarci in diretta quando il codice che stiamo scrivendo contiene delle inconsistenze (ad esempio, se proviamo a chiamare una variabile come se fosse una funzione, o ad accedere a una proprietà che non esiste su un oggetto).  La cosa bella è che, nel caso specifico di Visual Studio Code, che costituisce ad oggi lo standard di fatto per lo sviluppo di applicazioni web, TypeScript è supportato nativamente senza l'aggiunta di estensioni.    Nel mondo degli IDE e dei linguaggi sviluppati da Microsoft, la capacità di autocompletamento e suggerimento di proprietà, metodi e parole chiave è storicamente chiamata IntelliSense; questa parola è stata in seguito adottata generalmente anche al di fuori del contesto degli ambienti di sviluppo Microsoft. Grazie all'intellisense, sapremo in tempo reale quali informazioni TypeScript ha raccolto sulla struttura dei nostri tipi.  Descrivere correttamente i tipi che utilizziamo nel nostro codice acquisisce così un importantissimo valore aggiunto in termini di produttività: portando la nostra attenzione dalle operazioni ai dati, chiariamo al momento stesso della stesura del codice quali sono le API sia del nostro codice, sia di framework e librerie in uso. Come vedremo, una corretta formalizzazione dei nostri tipi costituisce la miglior rappresentazione formale degli scopi e dei meccanismi del nostro software e ne favorisce la comprensione, la manutenzione e l'estensione.  Dalla prossima sezione di questa guida risulterà via via sempre più chiaro come possiamo utilizzare TypeScript sia per la validazione formale del codice, sia per facilitare la scrittura.

4

Il sistema di tipi in Typescript

4.1

Tipi primitivi in Typescript

Essendo TypeScript un linguaggio che include JavaScript, come base di partenza il suo sistema di tipi ricalca i tipi primitivi esistenti a runtime; dunque ritroviamo i già noti string, number, boolean. A questi si aggiungono i tipi object e array, che però individuano intere categorie di sottotipi e per questo li vedremo più avanti nel dettaglio.  Quanto ai tipi primitivi, attenzione a non usare le controparti con la maiuscola di questi tipi: String, Number, Boolean rappresentano le funzioni che utilizziamo per le conversioni di tipo, e utilizzate nelle annotazioni di tipo non rappresentano i rispettivi tipi, ma dei tipi speciali che concretamente non vengono praticamente mai usati nemmeno nel linguaggio JavaScript.  Altri tipi primitivi meno comuni che potremmo incontrare sono bigint e symbol. Questi tipi sono utilizzati in casistiche molto specifiche, che esulano dagli scopi di questa guida.
4.2

Tipi letterali in Typescript

In aggiunta ai tipi primitivi, TypeScript offre un modo molto vantaggioso per vincolare la definizione di un tipo a uno o più specifici valori letterali che una data variabile può assumere, o che una data funzione può restituire.  Prendiamo il seguente esempio:  let a = 1; // a: number const b = 1; // b: 1 Come è facile aspettarsi, il tipo inferito per a è number, e infatti ha perfettamente senso che la variabile a sia riassegnata con altri valori numerici in altri punti del codice. Essendo invece b una costante, TypeScript è in grado di restringere ulteriormente il suo tipo, vincolandolo esclusivamente al valore 1, che qui viene usato come tipo.  Qualunque notazione letterale può essere usata come tipo; vediamo un po' di esempi: let a: 42; let b: "hello"; let c: null; let d: undefined; let e: false; Queste variabili sono vincolate ad avere un solo valore. Si potrebbe pensare che non abbia alcun senso definire delle variabili con tale vincolo, e infatti è proprio così: una variabile che può assumere un singolo valore è di fatto una costante! Ma i tipi letterali sono utilissimi quando combinati con altri tipi: ci permettono infatti di ridurre i possibili valori di una variabile a un insieme definito e vincolato di opzioni possibili:  function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 { ... } La funzione rollDice, che abbiamo incontrato in precedenza, restituisce necessariamente un numero da 1 a 6. In questo caso abbiamo combinato sei diversi tipi letterali con una notazione speciale che approfondiremo più avanti, e che essenzialmente significa oppure, analogamente all'operatore booleano || che usiamo nel linguaggio JavaScript. 
4.3

Null e undefined in Typescript

Un'altra combinazione frequentissima avviene tra i tipi null e undefined e altri tipi, a indicare che un dato potrebbe essere valorizzato oppure mancante. Prendiamo l'API standard del localStorage, disponibile in tutti i browser:  localStorage.getItem("key"); // getItem(): string | null In questo caso, getItem restituisce un valore stringa oppure niente, e niente è rappresentato dal valore null. Anche undefined serve spesso lo stesso scopo, e spesso la differenza tra i due è meramente convenzionale, vedendo in null un valore nullo assegnato, mentre in undefined un valore nullo derivante da una mancata assegnazione di una variabile. Per quello che interessa ai fini pratici, questi due valori presentano caratteristiche analoghe, e infatti TypeScript ci permette di adottare per entrambi una speciale asserzione di non-nullità.  Immaginiamo di essere certi di aver valorizzato la chiave key nel localStorage con una stringa, e di volerne ricavare la lunghezza:  const value = localStorage.getItem("key"); // value: string | null const len = value.length; // 'value' is possibly 'null' Qui TypeScript non è certo di poter leggere la proprietà length su value, e in base alle nostre impostazioni di strictness, il type checker protesterà: se dovessimo sbagliarci sulla presenza della chiave key, incapperemmo in un errore a runtime, e il lavoro di TypeScript è proprio quello di prevenire questo tipo di situazioni.   Possiamo gestire questa casistica in due modi.  Se vogliamo agire in modo prudente, ci basterà tentare la navigazione verso la proprietà length con l'operatore di navigazione sicura ?.. Questo operatore è nativamente valido in JavaScript ed essenzialmente restituirà il valore di length se possibile, altrimenti undefined:  const value = localStorage.getItem("key"); // value: string | null const len = value?.length; // len: number | undefined Se invece siamo assolutamente certi della presenza di key sul localStorage, e se siamo disposti a far scoppiare il nostro programma in caso di errore, possiamo usare l'operatore di asserzione di non-nullità ! (spesso chiamato bang):  const value = localStorage.getItem("key"); // value: string | null const len = value!.length; // len: number In questo caso non stiamo in nessun modo modificando la logica di navigazione (infatti ! esiste solo per TypeScript e viene rimosso nel passaggio a JavaScript); stiamo soltanto asserendo che value sia di un tipo alternativo a null o undefined, quindi in questo caso string. In altre parole, l'uso di ! qui equivale a scrivere:  const value = localStorage.getItem("key"); // value: string | null const len = (value as string).length; // len: number
4.4

Tipi enumerativi in Typescript

A volte ci troviamo in presenza di tipi di dato il cui unico scopo è quello di discriminare tra un elenco finito di opzioni.  Prendiamo ad esempio la seguente funzione, che presenta uno switch statement: function log(level: string, message: string): void { switch (level) { case "log": console.log(message); return; case "info": console.info(message); return; case "warning": console.warn(message); return; case "error": console.error(message); return; } } Lo scopo della funzione log è di unificare la chiamata ai diversi metodi di console, passando un parametro level il cui scopo è quello di discriminare quale livello di severità applicare. Il problema è che, definendo level: string, ammettiamo anche valori che non hanno senso rispetto ai casi definiti.  Un modo per evitare questo problema a runtime è aggiungere un caso default nel nostro blocco switch, e scatenare un'eccezione: function log(level: string, message: string): void { switch (level) { case "log": console.log(message); return; case "info": console.info(message); return; case "warning": console.warn(message); return; case "error": console.error(message); return; default: throw new Error("Invalid log level value"); } } Sicuramente considerare tutte le casistiche è un'ottima pratica, ma TypeScript può aiutarci con una dichiarazione di tipo molto speciale, introdotta dalla parola chiave enum:  enum LogLevel { Log, Info, Warning, Error } function log(level: LogLevel, message: string): void { switch (level) { case LogLevel.Log: console.log(message); return; case LogLevel.Info: console.info(message); return; case LogLevel.Warning: console.warn(message); return; case LogLevel.Error: console.error(message); return; } } Quando dichiariamo un tipo enum, non facciamo altro che elencare i possibili valori che questo tipo può assumere, senza preoccuparci di individuare un valore corrispondente per le diverse opzioni. Proprio per questo motivo, la dichiarazione di tipi enum è così particolare: è uno dei rari casi in cui il compilatore scrive attivamente codice JavaScript al posto nostro, invece che limitarsi a verificare e rimuovere le dichiarazioni e le annotazioni di tipo.  Non è un caso che per l'esempio sia stato usato un costrutto switch: i tipi enum si prestano particolarmente per questo genere di casistiche, permettendo al type checker di limitare i valori alle sole opzioni definite dal tipo, e all'IDE di suggerirci l'autocompletamento di tutti i case.  Visto che i tipi enum comportano un'implementazione JavaScript, vediamo un attimo il codice generato dal compilatore per LogLevel: var LogLevel; (function (LogLevel) { LogLevel[LogLevel["Log"] = 0] = "Log"; LogLevel[LogLevel["Info"] = 1] = "Info"; LogLevel[LogLevel["Warning"] = 2] = "Warning"; LogLevel[LogLevel["Error"] = 3] = "Error"; })(LogLevel || (LogLevel = {})); Il risultato di questo stranissimo codice è, in effetti, molto più semplice di quello che sembra; questo è il valore di LogLevel dopo l'esecuzione di questa strana funzione: { '0': 'Log', '1': 'Info', '2': 'Warning', '3': 'Error', Log: 0, Info: 1, Warning: 2, Error: 3 } In pratica, TypeScript ha creato un oggetto che mappa dei valori numerici ai nomi delle opzioni dell'enum, e poi mappa le opzioni dell'enum agli stessi valori numerici. Come mai questa strana simmetria?  Il punto è che, affinché JavaScript possa attribuire un significato alle opzioni enumerate, queste corrispondono in realtà a dei numeri che iniziano da 0, a salire. È anche possibile iniziare da numeri diversi da 0, oppure attribuire valori arbitrari ad ogni singola opzione:  enum OneToFour { One = 1, Two = 2, Three = 3 Four = 4 } enum PrimeNumbers { Two = 2, Three = 3, Five = 5 } Grazie a questa implementazione così sofisticata, potremo accedere ai diversi valori dell'enum sia attraverso il loro indice numerico, sia attraverso il nome delle opzioni: enum LogLevel { Log, Info, Warning, Error } const log = LogLevel["Log"]; console.log(log); // 0 const info = LogLevel[1]; console.log(info); // Info TypeScript ci offre anche la possibilità di dare valori stringa alle opzioni dell'enum, il che semplifica il codice risultante: enum LogLevel { Log = "Log", Info = "Info", Warning = "Warning", Error = "Error" } const log = LogLevel.Log; console.log(log); // Log Questo codice risulta nel seguente JavaScript: var LogLevel; (function (LogLevel) { LogLevel["Log"] = "Log"; LogLevel["Info"] = "Info"; LogLevel["Warning"] = "Warning"; LogLevel["Error"] = "Error"; })(LogLevel || (LogLevel = {})); var log = LogLevel.Log; console.log(log); // Log In effetti, l'implementazione risulta più semplice perché, a questo punto, non è più necessario mappare gli indici numerici.  Gli enum sono molto pratici quando dobbiamo discriminare tra un numero finito di possibilità, e sono ampiamente utilizzati in linguaggi di programmazione tendenzialmente orientati agli oggetti come il linguaggio C# e il linguaggio Java. Ad ogni modo, poiché gli enum nascondono un'implementazione e, in fin dei conti, rappresentano dei normalissimi valori numerici o testuali dietro a un tipo, sono ritenuti abbastanza eccezionali in TypeScript e hanno un utilizzo circoscritto a determinate casistiche come quella mostrata nell'esempio o a stili di programmazione fortemente orientati agli oggetti.  Più avanti vedremo come combinare i tipi algebrici e quelli letterali per ottenere qualcosa di simile a dei tipi enum, ma senza la complessità introdotta dalla loro implementazione.
4.5

Oggetti in Typescript

Nel linguaggio JavaScript, praticamente ogni cosa è un oggetto ad eccezione dei tipi primitivi. Un oggetto è, fondamentalmente, una collezione di coppie chiave/valore (in inglese: key/value pair); in informatica, una simile struttura di dati è spesso chiamata mappa, dizionario o record.  Non confondiamo il fatto che un oggetto risponda alla definizione di mappa con la classe JavaScript Map che implementa, per l'appunto, una mappa, ed è nato appositamente per gestire casi d'uso dove un oggetto potrebbe anche andare bene, ma risulterebbe limitante o non ottimizzato per lo scopo. In questa guida non parliamo di Map.  Il caso d'uso più frequente per gli oggetti in JavaScript sta nell'aggregare informazioni in delle proprietà; il modo più facile per rappresentare le proprietà di un oggetto è attraverso un object literal type:  const user: { name: string, email: string } = { name: "John Doe", email: "[email protected]" }; In questo caso abbiamo rappresentato il tipo di user con una notazione analoga a quella con cui gli abbiamo assegnato le sue proprietà. Poiché il tipo di user non ha un nome, si dice che è anonimo.  Se volessimo dare un nome al tipo di user, ad esempio User (senza troppa fantasia), possiamo farlo in due modi: o assegnando un type alias alla notazione letterale anonima, oppure dichiarando un'interfaccia: // alias di tipo type User = { name: string, email: string, }; // interfaccia interface User { name: string; email: string; } Sia nella notazione anonima sia in un'interfaccia, la virgola e il punto e virgola per separare le proprietà sono intercambiabili e anzi, addirittura facoltativi, se le proprietà sono poste su righe separate.  Poiché esiste un'ampia sovrapposizione tra type e interface, è il caso di chiedersi quali sono i casi d'uso più appropriati per l'una e per l'altra dichiarazione.  In generale, interface è il modo ufficialmente raccomandato per dichiarare la struttura di un oggetto; è altresì vero che con le interfacce possiamo rappresentare anche array, funzioni e costruttori, che a rigore per il linguaggio JavaScript non sono altro che oggetti con particolari caratteristiche. Anche se non siamo abituati a concepire tutti questi tipi di dato come oggetti, usando un'interfaccia per rappresentarli, stiamo ufficializzando quella data struttura come un tipo vero e proprio con un suo nome e una collocazione ben precisa nel type system.  Con type, invece, possiamo mettere qualsiasi tipo o combinazione di tipi dietro a un alias, più o meno allo stesso modo in cui a runtime possiamo mettere in una costante qualsiasi valore risultante da un'espressione; possiamo assegnare un alias ad un tipo primitivo tanto quanto ad un oggetto, a un array o a una qualunque combinazione di tipi, e TypeScript non farà altro che rimpiazzare quell'alias con la sua definizione. Il team di TypeScript raccomanda di usare type quando si vuole dare un nome a una combinazione complessa di tipi non rappresentabile con un'interfaccia.  Esistono altre differenze tra alias e interfacce che vedremo man mano che esploriamo il type system. In fondo alla guida vedremo anche come i due approcci alla definizione dei tipi siano in realtà più o meno preferibili in base allo stile di programmazione adottato.
4.6

Array e tuple in Typescript

Array in Typescript Nel linguaggio JavaScript, un elenco ordinato di dati è rappresentato da un array; in TypeScript, gli array sono elenchi ordinati di dati dello stesso tipo.  Ecco qualche esempio di array in TypeScript: // array di stringhe const greetings: string[] = ["hello", "hi"]; // array di numeri const oneToFive: number[] = [1, 2, 3, 4, 5]; // array di array di numeri const data: number[][] = [ [1, 2, 3, 4], [2, 4] ]; Ovviamente, se volessimo mettere insieme elementi di diverso tipo in un array, sarà sufficiente identificarlo come un array di un tipo che comprende tutti i possibili tipi di dato presenti nell'array; al limite, anche se non è consigliabile, potremmo utilizzare any[] per indicare un array che potrebbe contenere qualunque cosa.   Tuple in Typescript Ricollegandoci a quanto appena detto e all'esempio riportato in precedenza, se, invece, volessimo rappresentare una sequenza ordinata di diversi tipi di dato, possiamo farlo con una tuple, a patto che tale sequenza abbia una cardinalità fissa (cioè che contenga sempre lo stesso numero di elementi).  Vediamo qualche esempio: // coppia di numero e booleano const couple: [number, boolean] = [1, false]; // tripla di booleano, booleano e stringa const triple: [boolean, boolean, string] = [false, true, "hello"]; Le tuple lavorano molto bene con l'assegnazione decostruita:  const triple: [boolean, boolean, string] = [false, true, "hello"]; const [fistBool, secondBool, str] = triple; Automaticamente, TypeScript assegnerà il tipo corretto a ogni variabile estratta dall'array. Ricalcando la sintassi di decostruzione, è anche possibile combinare array e tuple:  // tuple con un array di stringhe in coda const numberAndStrings: [number, ...string[]] = [1, "hello", "bye"]; const [num, ...strings] = numberAndStrings; // array di coppie di numeri const couples: [number, number][] = [ [1, 2], [5, 4], [3, -5] ]; Tutti i tipi di array che abbiamo visto possono essere rappresentati dietro un type alias, usando la parola chiave type: type Strings = string[]; type NumberFollowedByStrings = [number, ...string[]]; const strs: Strings = ["hello", "world"]; const numAndStrs = [1, ...strs]; Come abbiamo anticipato parlando di oggetti e interfacce, poiché un array in JavaScript è un particolare tipo di oggetto, è anche possibile rappresentare array e tuple dichiarando delle interfacce, ma la sintassi per farlo è decisamente più macchinosa:  // array di numeri interface Numbers extends Array<number> {} // tupla di numero e stringa interface NumberAndString extends Array<number | string> { 0: number; 1: string; } const nums: Numbers = [1, 2, 3]; const numAndStr: NumberAndString = [1, "hello"]; In questo esempio, incontriamo la parola chiave extends, che permette di ereditare la definizione di un'altra interfaccia, in questo caso Array<T>, che altro non è che il vero nome di quello che finora abbiamo chiamato T[], dove T è il tipo degli elementi dell'array.   Nel caso di Numbers stiamo semplicemente estendendo un array di numeri senza aggiungere altro. In generale, non è una buona pratica estendere un'interfaccia senza aggiungere nulla di strutturale: in questo caso, stiamo, di fatto, semplicemente dando un altro nome a un tipo già esistente, e per questo scopo ha molto più senso usare un type alias.  Nel secondo esempio, estendiamo un array che potrebbe contenere numeri o stringhe, e poi nella struttura dell'interfaccia fissiamo un numero in posizione 0, e una stringa in posizione 1. Il tipo che ne risulta è chiaramente una tupla, ma definita attraverso la struttura dell'oggetto che la rappresenta: un oggetto infatti in JavaScript può avere chiavi di tipo string ma anche number; le sole chiavi numeriche non sarebbero sufficienti, perché un array ha anche tutti i suoi metodi come push, map, pop, eccetera, ed è per questo che estendiamo Array. Se non estendessimo Array, perderemmo anche la possibilità di destrutturare la tupla usando le parentesi quadre.  Anche in questo caso, come per gli oggetti notiamo che TypeScript ci offre una visione duale del type system: da un lato, come avviene per gli enum, abbiamo una rappresentazione dei tipi più rigorosa e gerarchica, dunque vicina al mondo della programmazione orientata agli oggetti; dall'altro, ci permette di ripiegare su una rappresentazione più snella e strutturale, dunque vicina al mondo della programmazione procedurale o funzionale, nonché alla controparte JavaScript dei tipi utilizzati.
4.7

Funzioni in Typescript

Veniamo, ora, ad un altro tassello fondamentale di ogni linguaggio di programmazione: le funzioni. In TypeScript esistono diversi modi per tipizzare una funzione, ricalcando i diversi modi con cui una funzione può essere implementata in JavaScript.  Iniziamo con la definizione più comune, quella introdotta dalla parola chiave function function: function sum(a: number, b: number): number { return a + b; } Negli esempi precedenti, abbiamo già incontrato più volte questa definizione; qui la definizione JavaScript e le annotazioni TypeScript sono estremamente integrate fra loro: le annotazioni poste dopo i parametri ne indicano il tipo, mentre quella posta tra i parametri e il corpo della funzione, indica il tipo del valore di ritorno. Il tipo void sta a rappresentare il tipo di ritorno di una funzione che non ritorna nessun valore: function log(message: string): void { console.log(message); } Esiste anche un tipo molto particolare per rappresentare funzioni da cui non si esce né con un valore né con void; prendiamo, ad esempio, una funzione che scatena un'eccezione, oppure una che prevede un loop infinito: tali funzioni hanno il tipo di ritorno never: // ciclo infinito function loop(): never { while (true) { console.log("I'm going to print this forever."); } } // throw error function error(): never { throw new Error("Questa funzione non tornerà al chiamante."); } In un certo senso, il tipo never è l'opposto del tipo any, rappresenta cioè un dato che non solo non esiste, ma che non può mai essere letto.  Può essere che una funzione ritorni diversi tipi di dato in base alle condizioni in cui viene chiamata. In questo caso, abbiamo diversi modi per tipizzarla: possiamo, generalmente, indicare un tipo composto come tipo di ritorno, oppure possiamo effettuare l'overload della sua firma.  Prendiamo ad esempio una funzione che scambi una coppia di valori contenente un numero e una stringa: type Value = number | string; function swap(t: [number, string]): [string, number]; function swap(t: [string, number]): [number, string]; function swap(t: [Value, Value]): [Value, Value] { return [t[1], t[0]]; } const a = swap([1, "hello"]); // a: [string, number] const b = swap(["hello", 1]); // a: [number, string] Grazie all'implementazione sovraccaricata (overloaded), TypeScript è in grado di inferire correttamente il tipo di a e b a partire dalle coppie di partenza.  Possiamo tipizzare parametri e valori di ritorno anche per le arrow function:  const sum = (a: number, b: number): number => a + b; const log = (message: string): void => console.log(message); Tutte le notazioni viste finora legano le annotazioni di tipo alla definizione della funzione, ma non ci permettono di definire un tipo che possiamo applicare alla variabile che fa riferimento alla funzione.  Ridefiniamo, dunque, le due funzioni di sopra con una notazione più generale, che descrive la funzione in astratto anziché tipizzarne la definzione: const sum: (a: number, b: number) => number = (a, b) => a + b; const log: (message: string) => void = message => console.log(message); La notazione è appena differente dalla precedente, persino più ripetitiva, ma è totalmente indipendente dalla dichiarazione JavaScript. Per questo, può essere identificata da un type alias e riutilizzata per definire molte funzioni diverse con la stessa firma: type BinaryOperator = (a: number, b: number) => number; const sum: BinaryOperator = (a, b) => a + b; const subtract: BinaryOperator = (a, b) => a - b; const multiply: BinaryOperator = (a, b) => a * b; const divide: BinaryOperator = (a, b) => a / b; Con le notazioni precedenti, questo sarebbe stato impossibile!  Prima di proseguire, visto che le funzioni in JavaScript sono oggetti, vediamo come possiamo definire un'interfaccia per lo stesso tipo BinaryOperator: interface BinaryOperator { (a: number, b: number): number; } Anche in questo caso le due definizioni type e interface sono del tutto equivalenti da un punto di vista pratico, ma vengono considerate in modo leggermente diverso da TypeScript: nel primo caso si sta dando un nomignolo a un tipo anonimo senza collocarlo nel type system; nel secondo si sta registrando un nuovo tipo a tutti gli effetti. Possiamo accorgerci di questa differenza formale in Visual Studio Code passando sopra al nome del tipo: nel caso del type alias TypeScript ci mostra l'intera definizione del tipo, mentre nel caso dell'interfaccia si limita a mostrarci il suo nome.
4.8

Guardie di tipo in Typescript

Un caso d'uso particolare delle funzioni è quello di usarle per fare type narrowing, cioè per verificare se un dato sia di un certo tipo. Abbiamo già parlato di restringimento di tipo in Typescript, facendo esempi come il seguente: function concat(a: string | number, b: string | number): string { if (typeof a === "string") return a + b; // a: string return String(a) + b; } Immaginiamo di voler delegare il controllo sul tipo di a a una fuzione isString:  function isString(data: string | number): boolean { return typeof data === "string"; } Se provassimo ad usare questa definizione dentro l'if di concat, TypeScript non ne sarebbe troppo convinto:  function isString(data: string | number): boolean { return typeof data === "string"; } function concat(a: string | number, b: string | number): string { if (isString(a)) return a + b; // Operator '+' cannot be applied to types 'string | number' and 'string | number'. return String(a) + b; } Possiamo chiarire a TypeScript che il booleano che esce da isString non è un booleano qualunque, ma la risposta alla domanda: "a è una stringa?". In questo modo TypeScript può fare lo stesso type narrowing che fa nell'esempio iniziale: function isString(data: string | number): data is string { return typeof data === "string"; } Usando data is string come tipo di ritorno, stiamo qualificando isString come una guardia di tipo (in inglese: type guard), cioè una funzione che TypeScript può usare per fare restringimento su una variabile. Il nome data è totalmente arbitrario; l'importante è che la keyword is sia usata sul parametro su cui agisce la guardia. 
4.9

Any vs unknown in Typescript

Più indietro in questa guida, in un esempio abbiamo parlato della funzione JSON.parse e del suo tipo di ritorno any; riprendiamo l'esempio:  const data = JSON.parse('{ "name": "John" }'); // const data: any console.log(data.name); Abbiamo già visto come dire a TypeScript di assumere che data sia di un certo tipo, e abbiamo anche visto come verificarlo senza asserzioni con una type guard.  Tuttavia, quello che ci dovrebbe allarmare è che, in presenza di any, siamo sì di fronte a un tipo sconosciuto, ma siamo anche scoperti dalla protezione del type checker.  Se solo esistesse un tipo analogo a any ma ancora soggetto a verifiche di tipo...  Introduciamo unknown, il tipo sconosciuto!  Il tipo unknown funziona come any in quanto può essere ristretto a qualunque tipo con un'asserzione o una guardia, ma non sopprime il type checker in fase di accesso.  Vediamo come l'uso di unknown modifica il nostro esempio: const data: unknown = JSON.parse('{ "name": "John" }'); console.log(data.name); // -> Property 'name' does not exist on type 'unknown' A questo punto, possiamo tranquillizzare il compilatore in uno dei seguenti modi: const data: unknown = JSON.parse('{ "name": "John" }'); // type assertion, da usare se siamo certi di quello che facciamo console.log((data as { name: string }).name); // type guard, se non conosciamo la provenienza del dato function hasName(x: unknown): x is { name: string } { return typeof x === "object" && x !== null && "name" in x; } if (hasName(data)) { console.log(data.name); } // type narrowing, che ci vincola però alla notazione 'oggetto[chiave]' if (typeof x === "object" && x !== null && "name" in x) { console.log(data["name"]); } I tipi any e unknown servono chiaramente propositi molto simili, ma hanno una storia molto diversa. any esiste sin dalla nascita di TypeScript, mentre unknown è stato introdotto solo di recente appositamente per aumentare il rigore del type checker sui valori di tipo ignoto. Al momento esiste una proposta nella community per trasformare tutti i valori di ritorno any in unknown nelle librerie che TypeScript usa per descrivere la tipizzazione delle funzioni JavaScript standard (ad esempio la DOM API, ma anche JSON.parse).  In generale, possiamo considerare unknown come la versione type safe di any. In generale, dovremmo sempre preferire unknown a meno che non sappiamo quello che facciamo. Esistono dei casi in cui, in porzioni molto isolate del codice, siamo oggettivamente non interessati alla supervisione del type checker, per questo molto spesso nei progetti grandi si impone come regola generale quella di non usare mai any e di indicare con un commento eventuali deroghe a questa regola. 
4.10

Tipi algebrici in Typescript

Nelle sezioni iniziali di questa guida abbiamo parlato del fatto che TypeScript offre un type system statico, e questo comporta che una variabile non possa cambiare tipo durante il suo ciclo di vita. Eppure, sappiamo che il nostro codice TypeScript è destinato a diventare JavaScript, che invece ha un type system dinamico. Come abbiamo visto per gli array, un compromesso tra il rigore di TypeScript e la flessibilità di JavaScript consiste nell'indicare una combinazione dei possibili tipi che un dato potrebbe assumere:  type Item = number | string | null; const items: Item[] = [null, 1, "hello", 42]; È finalmente giunto momento di sviscerare il principio che sta alla base di tipi come Item e dell'operatore |, ma anche del suo fratello &. Il motivo per cui affrontiamo questo argomento solo ora è che, un po' come l'algebra, i tipi algebrici sono un costrutto tanto utile quanto avanzato; i tipi algebrici sono, generalmente, utilizzati in linguaggi funzionali come Haskell e F# che, per quanto stiano guadagnando popolarità, sono pur sempre dei linguaggi di programmazione meno diffusi dei linguaggi C-like come il linguaggio Java, C# e, per l'appunto, il linguaggio Javascript.  In TypeScript è posssibile definire un tipo come combinazione di due o più altri tipi, secondo le operazioni insiemistiche di intersezione o unione. I tipi così definiti sono detti tipi algebrici (in inglese: algebraic data types).    Tipo unione in Typescript (union type) Con l'operatore | rappresentiamo un tipo unione (in inglese: union type), cioè un tipo che rappresenta la possibilità di appartenere a uno dei tipi elencati:  type NumberOrString = number | string; type Bit = 0 | 1; type Bool = true | false; // Bool = boolean let a: NumberOrString = 2; a = "hello"; a = new Date(); // Type 'Date' is not assignable to type 'NumberOrString'. let bit: Bit = 0; bit = 1; bit = 3; // Type '3' is not assignable to type 'Bit'. let bool: Bool = false; bool = true; Con l'unione di tipi possiamo sfruttare la flessibilità di JavaScript mantenendo un certo rigore sulla definizione dei possibili tipi che la nostra variabile può assumere: quando andremo in lettura su una variabile che ha come tipo uno union type, TypeScript ci obbligherà a fare un narrowing o un'asserzione esplicita per identificare il sottotipo concreto che la variabile assume in quel punto del codice.  Dal tipo Bool definito nell'esempio possiamo notare anche che TypeScript sia perfettamente consapevole che la sua definizione coincide con il tipo primitivo boolean, che, infatti, non è altro che un tipo che può assumere uno di due valori letterali. Un'altra categoria di tipi che possono solo assumere un elenco finito di valori letterali sono gli enum, che abbiamo visto in precedenza; infatti, è molto comune usare | con numeri o stringhe per creare una versione semplificata e leggera di enum.  Vediamolo in un esempio con tanto di switch/case:  // possiamo dichiarare un'unione anche su più righe type Status = | "idle" | "loading" | "error" | "success"; function logStatusMessage(status: Status): void { switch (status) { case "idle": console.log("nothing happened :|"); return; case "loading": console.log("please wait..."); return; case "error": console.log("ops, something went wrong :("); return; case "success": console.log("we did it! :D"); return; default: // questo caso è teoricamente impossibile throw new Error("Unreachable code"); } } Qui abbiamo fatto la stessa identica cosa che avremmo fatto con un enum, ma a tutti gli effetti ci siamo risparmiati l'implementazione dell'enum a runtime e abbiamo usato delle semplici stringhe come tag per i possibili casi. Nelle sezioni finali di questa guida ci spingeremo ancora oltre, associando per ogni caso un payload specifico, una tecnica molto comune per la gestione degli stati (in inglese: state management).    Tipi intersezione in Typescript (Intersection Types) Vediamo, ora, la controparte degli union types, cioè i tipi intersezione; anche in questo caso andiamo a combinare più tipi per formarne uno nuovo, ma questa volta il tipo risultante andrà a identificare i dati che appartengono a entrambi i tipi di partenza:  type Entity = { id: number }; type User = { name: string; email: string; }; type UserEntity = Entity & User; const user: User = { name: "John Doe", email: "[email protected]" }; const userEntity: UserEntity = { id: 1, ...user }; In questo caso, poiché userEntity ha tutte le proprietà richieste da User e da Entity, soddisfa il tipo UserEntity.  Notiamo che, a differenza degli esempi fatti per gli union types, qui non abbiamo usato primitivi ma oggetti, e il motivo è presto rivelato: l'intersezione tra tipi individua quei dati che hanno l'unione delle proprietà di tali tipi; è controintuitivo, ma ha perfettamente senso da una prospettiva insiemistica, ed è altrettanto vero l'opposto per i tipi unione.  Tuttavia, non essendoci proprietà compatibili per essere unite tra i tipi primitivi, l'intersezione tra questi è sempre il tipo never. Vediamolo nell’esempio, nel paragrafo a seguire.   Tipo never in Typescript // A = B = C = never type A = number & string; type B = string & boolean; type C = number & boolean; Queste intersezioni non sono molto utili dato che nessun valore (nemmeno null o undefined) può essere assegnato a never. Intuitivamente, risulta chiaro come un oggetto possa soddisfare più tipi se include tutte le proprietà richieste da ognuno di essi, ma in nessun caso una variabile potrà mai (per l'appunto, never) essere contemporaneamente un numero e una stringa, nemmeno nell'iperflessibile type system di JavaScript.  Ovviamente la stessa cosa avviene per intersezioni di literal types:  // A = B = C = never type A = "hello" & "hi"; type B = 1 & 4; type C = true & 42; A questo punto, si potrebbe pensare che questo valga anche per l'intersezione tra primitivi e oggetti, ma c'è il tranello, che vediamo nel paragrafo successivo.   Branded types in Typescript  type A = string & Date; type B = number & string[]; type C = 3 & { name: string }; Notiamo che nessuno di questi tipi viene ridotto a never, nonostante sia chiaramente impossibile creare variabili di questi tipi. Il motivo per questa strana eccezione è che ci consente di creare dei tipi speciali che rappresentano un payload di tipo primitivo in una struttura unica riconoscibile dal type checker, senza però incapsulare realmente questo in un oggetto. Questi tipi speciali vengono chiamati branded types (tipi marchiati) e abilitano delle particolari tecniche che ovviamente vedremo più avanti, non prima di aver finito il nostro tour del type system. 
4.11

Parametri di tipo in Typescript

A volte può tornarci comodo parametrizzare i tipi allo stesso modo in cui facciamo con i dati. Prendiamo una funzione estremamente semplice, che accetta un dato e ritorna lo stesso dato immutato; come definiremmo la sua firma in TypeScript? const identity = x => x; // identity: ?? In questo esempio, TypeScript si limita a dirci che x è implicitamente di tipo any e, infatti, il tipo inferito di identity è (x: any) => any, che non è molto soddisfacente: sappiamo benissimo che il tipo di ritorno è esattamente identico al tipo dell'argomento passato, perché è evidente dall'implementazione.  Possiamo, dunque, parametrizzare il tipo di x insieme a x: const identity = <T>(x: T) => x; In questo modo il tipo di identity è <T>(x: T) => T, cioè una funzione che prende un parametro di un certo tipo e restituisce lo stesso tipo del parametro passato. Le definizioni che presentano parametri di tipo sono chiamate tipi generici (in inglese: generics), perché ci permettono di fissare dei vincoli di tipo senza concretizzare un tipo particolare. Oltre alle funzioni, i parametri di tipo possono essere usati nella definizione di nuovi tipi:  interface Has<T> { value: T; } type Maybe<T> = T | null; type MaybeNumber = Maybe<number>; Abbiamo già incontrato un tipo generico molto comune: Array<T>, anche noto come T[]. Grazie ai generics, possiamo definire tipi che sono di fatto funzioni su tipi, cioè funzioni che accettano tipi come argomenti e restituiscono nuovi tipi. È esattamente ciò che accade con Maybe<T> e MaybeNumber, tanto per fare un esempio.  Proprio come i parametri nel linguaggio JavaScript vengono vincolati ad un certo tipo in TypeScript con le annotazioni di tipo, anche i parametri di tipo possono essere vincolati ad appartenere a determinate categorie di tipi; per farlo usiamo la parola chiave extends:  type Primitive = boolean | number | string; interface Box<T extends Primitive> { value: T; } const box = <T extends Primitive>(value: T): Box<T> => ({ value }); const unbox = <T extends Primitive>(box: Box<T>): T => box.value; const boxedNumber = box(42); // boxedNumber: Box<42> const boxedBoolean = box(unbox(boxedNumber) + 1 === 43); // boxedBoolean: Box<boolean> In questo esempio è come se avessimo tipizzato un parametro di tipo: abbiamo vincolato il parametro T a Primitive, dopodiché lo abbiamo trattato come un tipo a sé, motivo per cui TypeScript ha capito perfettamente che l'operazione all'ultima riga è una somma valida tra numeri.  Ovviamente, in questo caso il vincolo a Primitive non era strettamente necessario, ma il senso di un tipo come Box è quello di incapsulare in un oggetto un valore, cosa che sarebbe ridondante se "boxassimo" un oggetto (che è già una box di valori primitivi).  Avremmo, dunque, potuto limitarci a indicare Primitive, senza parametrizzare il tipo? Vediamo cosa sarebbe cambiato in quel caso:  type Primitive = boolean | number | string; interface Box { value: Primitive; } const box = (value: Primitive): Box => ({ value }); const unbox = (box: Box): Primitive => box.value; const boxedNumber = box(42); // boxedNumber: Box const boxedBoolean = box(unbox(boxedNumber) + 1 === 43); // Operator '+' cannot be applied to types 'string | number | boolean' and 'number'. Ora TypeScript non può più assicurare che l'addizione all'ultima riga sia valida, perché l'informazione su quale sottotipo di Primitive sia stato inscatolato in boxedNumber è andata persa. Usando il tipo più ampio come un vincolo anziché un tipo abbiamo fatto fare a TypeScript il narrowing in compilazione e, dunque, ce lo possiamo risparmiare in esecuzione. 
4.12

Manipolazione di tipi in Typescript

A questo punto, abbiamo tutti gli strumenti per usare il type system come un vero e proprio linguaggio di programmazione a compile time. In questa sezione vedremo come creare tipi a partire da altri tipi, sfruttando vincoli e condizioni per creare complesse strutture a partire da pochi tipi di riferimento o persino da dati JavaScript non tipizzati.  Iniziamo subito estendendo quello che abbiamo visto sugli object types.  Ti avvertiamo: sarà una carrellata! In questa sezione della guida Typescript in italiano andiamo semplicemente a dimostrare le infinite possibilità offerte dal type system, senza individuare esempi e casi d'uso approfonditi per ognuna di esse, perché sarebbe pesantissimo e poco interessante; sarà comunque molto utile avere una panoramica completa di come si può assumere un controllo molto capillare dei tipi per una vasta varietà di situazioni specifiche. Cominciamo! Usando il punto di domanda ? possiamo indicare su un'interfaccia o un object literal che una certa proprietà può anche non essere presente. Implicitamente, tale proprietà diventerà potenzialmente undefined: interface OptionalProp { prop?: string; } const a: OptionalProp = {}; const b: OptionalProp = { prop: "hello" }; const c: OptionalProp = { prop: undefined }; Indicare una proprietà come opzionale non equivale a indicarla come | undefined: in quel caso, saremmo comunque obbligati a inizializzarla come in c, mentre l'inizializzazione di a non sarebbe valida. Possiamo anche annotare alcune proprietà di un oggetto come di sola lettura con la parola chiave readonly:  interface ReadonlyProp { readonly prop: string; } const a: ReadonlyProp = { prop: "hello" }; a.prop = "hi"; // Cannot assign to 'prop' because it is a read-only property. Se non conosciamo tutte le proprietà di un oggetto ma ne conosciamo la forma, possiamo indicarlo con una firma d'indice (in inglese: index signature): interface IndexSignature { [key: string]: number; } const a: IndexSignature = { someProp: 21, someOtherProp: 42 }; Le firme d'indice possono essere applicate insieme alla definizione di altre proprietà, a patto che queste rispettino tale firma: interface IndexSignature { [key: string]: number; prop1: number; prop2: string; // Property 'prop2' of type 'string' is not assignable to 'string' index type 'number'. } Partendo da un tipo oggetto possiamo ricavare il tipo delle sue proprietà usando la notazione oggetto["chiave"]: interface SomeProps { num: number; str: string; bool: boolean; } type Num = SomeProps["num"]; // Num = number type Str = SomeProps["str"]; // Str = string type Bool = SomeProps["bool"] // Bool = boolean Possiamo fare la stessa cosa con le tuple, indicando l'indice numerico al posto della chiave: type Tuple = [number, string, boolean]; type Num = Tuple[0]; // Num = number type Str = Tuple[1]; // Str = string type Bool = Tuple[2] // Bool = boolean Con l'operatore keyof otteniamo l'unione delle chiavi di un object type: interface SomeProps { num: number; str: string; bool: boolean; } type Key = keyof SomeProps; // Key = "num" | "str" | "bool" Possiamo fare l'operazione inversa, indicando un oggetto che ha come chiavi le stringhe di un union type; tali tipi sono detti mapped types: type Keys = "prop1" | "prop2" | "prop3"; type KeyNumber = { [K in Keys]: number; } const a: KeyNumber = { prop1: 1, prop2: 2, prop3: 3 }; Possiamo applicare delle condizioni sui tipi usando la parola chiave extends insieme all'operatore ternario: type Keys = "num" | "str" | "bool"; type KeyValue = { [K in Keys]: K extends "num" ? number : K extends "str" ? string : boolean; } const a: KeyValue = { num: 1, str: "hello", bool: false }; Un mapped type non può essere definito con un'interfaccia, ma solo come type alias. Qui K funziona come un parametro di tipo che itera tra le possibili opzioni dell'unione.  Tramite i mapped types possiamo applicare i modificatori delle proprietà appena visti e persino applicare condizioni per ricavare tipi oggetto da altri tipi oggetto: interface Model { readonly id: number; createdAt: Date; name: string; description?: string; } // rende readonly tutte le proprietà type ReadonlyModel = { readonly [K in keyof Model]: Model[K]; }; // rende opzionali tutte le proprietà type AllOptionalModel = { [K in keyof Model]?: Model[K]; }; // rende non-readonly tutte le proprietà type AllMutableModel = { -readonly [K in keyof Model]: Model[K]; }; // rende non-opzionali tutte le proprietà type AllRequiredModel = { [K in keyof Model]-?: Model[K]; }; // rende booleane tutte le proprietà type AllBooleanModel = { [K in keyof Model]: boolean; }; // rimuove tutte le proprietà che non hanno tipo 'number' type OnlyNumberPropsModel = { [K in keyof Model as Model[K] extends number ? K : never]: Model[K]; }; I tipi condizionali possono essere utilizzati anche in combinazione con i generics: type NumberOrNever<T> = T extends number ? T : never; type A = NumberOrNever<12>; // A = 12 type B = NumberOrNever<string>; // B = never type C = NumberOrNever<"hello">; // C = never type D = NumberOrNever<number | string>; // D = number Per concludere, vediamo come possiamo ricavare tipi dai dati JavaScript.  La parola chiave typeof espone il tipo di una variabile: const two = 2; type Two = typeof two; // Two = 2 let num = 1; type Num = typeof num; // Num = number const user = { name: "John Doe", email: "[email protected]", phone: "345 678 9012" }; type User = typeof user; // User = { // name: string; // email: string; // phone: string; // } Questo può essere molto comodo quando si vuole definire un certo oggetto e il suo tipo contemporaneamente.  Va notato che quando una variabile o proprietà è modificabile, TypeScript le assegna un tipo non letterale, mentre se è costante le assegna il tipo letterale corrispondente al suo valore. Se volessimo trattare un intero oggetto o array come immutabile per ricavarne esattamente il tipo letterale, possiamo usare as const; vediamo come cambiano le definizioni precedenti: const two = 2; // as const qui non serve type Two = typeof two; // Two = 2 let num = 1 as const; type Num = typeof num; // Num = 1 const user = { name: "John Doe", email: "[email protected]", phone: "345 678 9012" } as const; type User = typeof user; // User = { // readonly name: "John Doe"; // readonly email: "[email protected]"; // readonly phone: "345 678 9012"; // } Le notazioni viste fin qui ci permettono di manipolare con chirurgica precisione ciò che vogliamo prelevare da un tipo o persino da un dato preesistente e come vogliamo modificarlo; tuttavia, non è verosimile pensare di usarle frequentemente visto che sono tante e piuttosto complesse e, dunque, non favoriscono particolarmente la leggibilità e la comprensione dei nostri tipi. La cosa più verosimile è che tali notazioni siano utilizzate in modo isolato per definire dei tipi generici utili per applicare le modifiche viste ai tipi esistenti nel nostro dominio.  Nella sezione seguente vedremo che la maggior parte di questi generici utili ci viene fornita nativamente da TypeScript.
4.13

Tipi utility in Typescript

Se volessimo ricapitolare quello che abbiamo imparato sul type system, potremmo sintetizzarlo così: TypeScript parte dai primitivi, i tipi nativamente presenti nel linguaggio JavaScript, e aggiunge un insieme di definizioni per vincolare la struttura di oggetti e array. Tali definizioni seguono una gerarchia nominale di tipi veri e propri costituiti dalle nostre interfacce, più una serie di tipi avanzati, prodotti per lo più dalla combinazione e trasformazione di altri tipi.  In questa sezione vediamo i cosiddetti utility types, cioè dei tipi generici il cui unico scopo è manipolare altri tipi in modi che risultano particolarmente pratici o comuni; ne vedremo i più frequenti. Anche qui, ti avvertiamo: seconda ed ultima carrellata! Anche in questo caso non ci sarà una progressiva esposizione del type system, ma piuttosto uno showcase delle opzioni a disposizione, in modo tale da lasciare un'idea abbastanza definita di ciò che è possibile realizzare con TypeScript. Nessuno si aspetta che tutti questi tipi siano conosciuti a memoria e, anzi, il fatto stesso di essere arrivati fin qui significa che i fondamentali sono acquisiti e da questo punto in poi ogni strumento in più è disponibile per consultazione. Cominciamo! Abbiamo visto come rendere opzionali alcune o tutte le proprietà di un'interfaccia; questa operazione può essere svolta con il tipo Partial<T>, che rappresenta qualunque sottoinsieme delle proprietà del suo tipo parametro.  Partial<T> è spesso utilizzato quando si vuole applicare una patch ad un oggetto preesistente, come negli esempi che seguono. const defaultClientConf = { baseUrl: "/", timeoutMs: 1000, maxRetry: 3, }; type Conf = typeof defaultClientConf; const makeConf = (conf: Partial<Conf>): Conf => ({ ...defaultClientConf, ...conf }); const conf = makeConf({ maxRetry: 10 }); // { // baseUrl: "/", // timeoutMs: 1000, // maxRetry: 10 // } Qui makeConf usa defaultClientConf come oggetto di partenza a cui applicare le customizzazioni per restituire la configurazione definitiva di un ipotetico client.  Vediamo un altro esempio: const copyWith = <T>(obj: T, diff: Partial<T>): T => ({ ...obj, ...diff }); const user = { id: 1, name: "John Doe", email: "[email protected]" }; const userCopy = copyWith(user, { email: "[email protected]" }); // { // id: 1, // name: "John Doe", // email: "[email protected]" // } Qui usiamo Partial<T> per creare copie di oggetti con alcune proprietà diverse dalle originali: questo caso è a tutti gli effetti una generalizzazione del precedente, dove makeConf non è altro che copyWith con il parametro obj fissato a defaultClientConf.  Se Partial<T> ci permette di ricavare un tipo con tutte proprietà opzionali, Required<T> fa l'opposto e rende tutte le proprietà obbligatorie. Questo tipo è sicuramente meno interessante del precedente, ma potrebbe tornare utile nel momento in cui avessimo un'interfaccia espressa con una serie di proprietà opzionali e volessimo obbligare il chiamante di una funzione ad esprimere tutti i valori esplicitamente.   Il tipo composto da proprietà readonly è espresso dall'utility Readonly<T>. Questa utility svolge un ruolo simile alla notazione as const e viene normalmente usata per indicare che non si muterà un oggetto:  function cannotModify(obj: Readonly<{ prop1: number, prop2: string }>): void { obj.prop1 = 33; // Cannot assign to 'prop1' because it is a read-only property. } cannotModify({ prop1: 1, prop2: "hello" }); Qui abbiamo usato Readonly<T> per indicare nella firma di cannotModify che questa funzione non altererà l'oggetto passatole come argomento. Questo tipo di scelta semantica può essere molto utile quando si sta scrivendo una funzione e si vuole chiarire che questa non applica mutazioni ai suoi parametri.  NB. La mutazione sul posto (in inglese: in place mutation), vale a dire la riassegnazione di nuovi valori a variabili e proprietà, è un'operazione computazionalmente economica ed efficiente, ma espone a diverse categorie di errori e problematiche; per questo motivo ha senso adottare strategie per evitarla o confinarla. Più avanti vedremo come diversi paradigmi di programmazione puntano ad evitare o confinare le mutazioni in place.   Il tipo che più fedelmente rappresenta un generico oggetto JavaScript è Record<K, T>, la cui implementazione è qualcosa di questo tipo:  type Record<K extends string | number | symbol, T> = { [P in K]: T; } Essenzialmente, un Record<K, T> è un oggetto con le chiavi di tipo K e i valori di tipo T. Una volta definito un oggetto come Record<K, T> è possibile accedere alle sue proprietà solo con la notazione oggetto["chiave"], poiché TypeScript, non conoscendo la struttura dell'oggetto, non ammette il riferimento diretto a una proprietà con il normale navigatore . : type StringDictionary = Record<string, string>; type Grouped<T> = Record<number, T[]>; const dictionary: StringDictionary = { someProp: "someValue", otherProp: "otherValue" }; const multiples: Grouped<number> = { 1: [1, 2, 3], 4: [4, 8, 12], 7: [7, 14, 21] }; const valueFromDic = dictionary["someProp"]; const valueFromMultiples = groups[1]; I record diventano interessanti quando usati con gli union types: type Key = "a" | "b" | "c"; type Value = "hello" | "hi"; const a: Record<Key, Value> = { a: "hello", b: "hi", c: "hello" } Qui abbiamo usato un Record<K, T> per creare un oggetto che combina un insieme finito di chiavi con un insieme finito di valori. Molto pratico, no?    Due tipi molto utili per estrarre proprietà da tipi esistenti sono Pick<T, K> e Omit<T, K>; il primo estrae un sottoinsieme con le chiavi elencate, il secondo senza.  Questi due tipi sono utilissimi nel momento in cui si vuole operare solo con alcune proprietà di un oggetto, escludendone altre, senza riscrivere il tipo con cui si vuole lavorare: interface User { id: number; username: string; email: string; description: string; } interface UserDb { select(id: Pick<User, "id"> | Pick<User, "email">): User; insert(user: Omit<User, "id">): User["id"]; update(diff: Pick<User, "id"> & Partial<Omit<User, "id" | "username">>): void; delete(id: User["id"]): void; } function example(db: UserDb): void { // seleziona utente per id o email const u1 = db.select({ id: 1 }); const u2 = db.select({ email: "[email protected]" }); // aggiungi utente const id = db.insert({ username: "john_doe", email: "[email protected]", description: "Junior full-stack dev" }); // aggiorna utente per id, senza modificare username db.update({ id, description: "Senior full-stack dev" }); // elimina utente per id db.delete(id); } In questo esempio c'è un bel po' di carne al fuoco: abbiamo implementato l'interfaccia di un database sul quale operare le cosiddette CRUD su un'entità User, e per ogni metodo abbiamo usato unioni e intersezioni insieme ai tipi utility appena visti per indicare esattamente quali proprietà e di quale tipo passare.  TypeScript ci ha risparmiato di definire tutte queste interfacce separatamente, e in questo modo abbiamo raggiunto una granularità difficilmente ottenibile in altri linguaggi.  Due tipi utili per lavorare con gli union types sono Exclude<A, B> e Extract<A, B>; il loro funzionamento di base è abbastanza ovvio: type ABC = "a" | "b" | "c"; type BC = Exclude<ABC, "a">; // "b" | "c" type A = Extract<ABC, "a">; // "a" Queste due utilità danno il meglio quando vengono usate con tipi assegnabili a quelli di partenza:  type Union = | { type: "A", payload: string } | { type: "B", payload: number } | { type: "C", payload: boolean } | null; // estrae il primo object type type A1 = Extract<Union, { type: "A" }>; type A2 = Extract<Union, { payload: string }>; // estrae i tre object type senza null type ABC1 = Exclude<Union, null>; type ABC2 = Extract<Union, {}>; // estrae il secondo e il terzo object type type BC1 = Extract<Union, { type: "B" | "C" }>; type BC2 = Exclude<Union, { type: "A" } | null>; Queste utility sono spesso usate per limitare a casistiche specifiche unioni che potrebbero essere più generiche del necessario.    I seguenti tipi utility operano con funzioni e costruttori: // estrae i parametri di un tipo funzione in una tupla type A = Parameters<(a: number, b: number) => void>; // [a: number, b: number] type B = Parameters<typeof Math.abs>; // [x: number] type C = Parameters<typeof fetch>; // [input: RequestInfo | URL, init?: RequestInit | undefined] // estrae i parametri di un costruttore type D = ConstructorParameters<new (a: number) => unknown>; // [a: number] type E = ConstructorParameters<typeof URLSearchParams> // [init?: string | string[][] | Record<string, string> | URLSearchParams | undefined] // estrae il tipo di ritorno di una funzione type F = ReturnType<typeof Math.random>; // number type G = ReturnType<typeof console.log>; // void // estrae l'istanza creata da un costruttore // di solito, non è molto interessante type H = InstanceType<typeof URLSearchParams>; // URLSearchParams // estrae il valore atteso da una promise type I = Awaited<Promise<number>>; // number // estrae anche promise annidate e unioni di promise type J = Awaited<Promise<Promise<string>> | boolean>; // string | boolean // utile insieme a ReturnType type K = Awaited<ReturnType<typeof fetch>>; // Response Visto che in TypeScript le stringhe vengono spesso utilizzate come etichette per creare e discriminare tipi, esistono anche delle utility per manipolarle; ma, attenzione: questi tipi manipolano i tipi stringa letterali, non i loro effettivi controvalori! type A = Uppercase<"abc">; // "ABC" type B = Lowercase<A>; // "abc" type C = Capitalize<"hey there">; // "Hey there" type D = Uncapitalize<C>; // "hey there" Siamo finalmente giunti alla fine di questa vastissima esplorazione. Nell'ultima parte di questa guida vedremo esempi e tecniche di applicazione pratica delle principali caratteristiche di TypeScript. In particolare, ci soffermeremo su come definire i nostri tipi e come organizzare il nostro codice di conseguenza, in base ai principali paradigmi di programmazione.

5

Paradigmi e Architetture in Typescript

5.1

Typescript e la programmazione procedurale/Scripting

Lungo tutta la guida la conoscenza di TypeScript e del suo sistema di tipi sono state il fulcro della nostra trattazione. Ma, in fin dei conti, un linguaggio di programmazione è tanto efficace quanto è espressivo e ci permette di organizzare il codice in un modo che rappresenti e descriva la realtà del dominio in cui il nostro software opera.  In queste discussioni finali vedremo come possiamo strutturare l'architettura del nostro codice secondo i principali paradigmi, partendo dal più semplice, nonché più vicino alla macchina: la programmazione procedurale, normalmente utilizzata nel cosiddetto scripting. Scrivere script significa essenzialmente scrivere degli elenchi di istruzioni per la macchina: ci concentriamo sul come fare le cose, passaggio per passaggio, organizzando le nostre istruzioni in modo tale che il risultato finale sia quello atteso.  Generalmente, nello scripting si fa un uso abbastanza limitato o nullo della segregazione dei moduli, vale a dire che si tende a integrare il codice attraverso l'uso e la modifica di variabili, funzioni e oggetti globali.  Questo è il campo in cui TypeScript ci tornerà meno utile. Come il nome stesso rivela, JavaScript nasce per essere un linguaggio di scripting, con cui animare il comportamento delle prime, semplicissime pagine web. Questo significa che la scala della nostra codebase è presumibilmente piccola e non abbiamo molto bisogno di tipizzare il nostro codice, perché le funzioni che definiamo sono sufficienti a farci comprendere il dominio in cui operiamo; non avremo nemmeno troppi problemi a modificare più volte tipi e valori delle nostre variabili.  Eppure, già in uno scenario così semplice ed elastico, possiamo utilizzare TypeScript per chiarire cosa intendiamo fare con le nostre variabili e funzioni.  Prendiamo ad esempio uno script che gestisce il dietro le quinte di una pagina web che rappresenta un contatore: <!DOCTYPE html> <html> <head> <meta charset='utf-8'> <meta http-equiv='X-UA-Compatible' content='IE=edge'> <title>Page Title</title> <meta name='viewport' content='width=device-width, initial-scale=1'> </head> <body> <p>Count: <span id="counter">0</span></p> <button id="increment">Increment</button> <button id="reset">Reset</button> <script src='main.js'></script> </body> </html>   // recuperiamo e tipizziamo gli elementi della pagina const incrementBtn = document.querySelector("#increment") as HTMLButtonElement; const resetBtn = document.querySelector("#reset") as HTMLButtonElement; const counterSpan = document.querySelector("#counter") as HTMLSpanElement; // iniziamo lo stato a 0 let count = 0; // count: number // attiviamo gli event handlers incrementBtn.addEventListener("click", increment); resetBtn.addEventListener("click", reset); // definiamo le "funzioni" (in realtà sono procedure) // che muteranno lo stato della nostra pagina function increment(): void { count++; updateView(); } function reset(): void { count = 0; updateView(); } function updateView(): void { counterSpan.innerText = String(count); } In questo blocco di codice si notano diverse cose tipiche della programmazione procedurale:  lo stato è rappresentato da variabili che vengono aggiornate durante l'esecuzione  non ci sono livelli di confinamento per lo stato dell'applicazione  le funzioni dichiarate sono in realtà procedure, nel senso che non fanno calcoli per restituire un risultato, ma eseguono istruzioni per aggiornare lo stato  In un caso così semplice (o anche un po' più complesso di così, ma non molto più complesso), l'organizzazione delle cose va più che bene; ben diverso è immaginare, ad esempio, di coinvolgere un backend che fornisca lo stato iniziale, gestire le logiche di caricamento e aggiornamento, eventuali errori di rete, eccetera. In genere, in una codebase organizzata in modo esclusivamente procedurale, il codice tenderà a riempirsi di controlli a runtime (if, switch) e istruzioni di control flow (cicli, eccezioni, ecc) perché ogni operazione è in teoria disponibile ma, prima di effettuarla, bisognerà verificare che lo stato corrente sia sensato rispetto a tale operazione. Cosa succederebbe se inizializzassimo lo stato a undefined mentre attendiamo la risposta dal server e qualcuno cliccasse il pulsante di reset o di incremento? Dovremmo inizializzare questi pulsanti in modo che siano disabilitati, e abilitarli manualmente all'arrivo dei dati.  E se tentando di aggiornare il contatore ci fosse un errore di comunicazione con il server? Dovremmo di nuovo ricordarci di disattivare i pulsanti. E se la nostra pagina avesse più di un contatore? Come faremmo a confinare i diversi contatori?  Queste casistiche non sono così rare: le moderne web app gestiscono decine di logiche in contemporanea, tra interazione dell'utente, comunicazione con uno o più back-end, animazioni, validazioni, eccetera.  La prima cosa da fare è assicurarsi di avere la possibilità di dividere e segregare le parti di codice che riguardano funzionalità diverse, e permettere a queste diverse parti di interagire secondo protocolli ben definiti. Ed è proprio in questo che TypeScript ci darà una gran mano.
5.2

Moduli e namespace in Typescript

Il primo livello di organizzazione architetturale del nostro codice è abbastanza integrato nelle moderne versioni di JavaScript; si tratta dei moduli. I moduli (o concetti ad essi analoghi) costituiscono la base di organizzazione del codice in praticamente tutti i linguaggi di programmazione.  Lo scopo di un sistema di moduli è molteplice:  dividere il codice in parti indipendenti tra loro  dichiarare con esattezza i rapporti di dipendenza tra moduli  ridurre la superficie di interazione tra moduli a specifici protocolli limitare il grado di complessità e le competenze a un ambito ben circoscritto Sin dai primi passi mossi nella programmazione informatica, praticamente ogni programmatore (per lo meno tra quelli ordinati) sente, ad esempio, l'esigenza di spezzare il codice in più files. In effetti, il sistema di moduli di JavaScript e, dunque, anche di TypeScript, individua in ogni file un modulo.   Che cos’è un modulo in Typescript? Un modulo è, essenzialmente, un file TypeScript con del codice che si comporta come una funzione quando viene inizializzato e come un oggetto quando viene utilizzato. Tutto quello che scriviamo in un file che presenta delle istruzioni di import o export viene eseguito una volta sola alla sua inizializzazione e, dopodiché, viene esportato come un oggetto a disposizione di altri moduli.  Immaginiamo di voler isolare il nostro contatore visto in precedenza in un modulo; creeremo un file counter.ts che contiene solo le logiche del contatore, escludendo l'interazione con il DOM:  let _count = 0; export function count(): number { return _count; } export function increment(): void { _count++; } export function reset(): void { _count = 0; } Eccoci qui con un modulo semplicissimo che incapsula (cioè nasconde all'esterno) il suo stato nella variabile _count ed espone tre funzioni, una per leggerne il valore, le altre due per modificarlo secondo la logica di un contatore.  Il codice che consuma il nostro modulo è ancora il nostro script principale che, però, è ora divenuto stateless, poiché non deve più prendersi cura di nessuna variabile: import * as counter from "./counter"; const incrementBtn = document.querySelector("#increment") as HTMLButtonElement; const resetBtn = document.querySelector("#reset") as HTMLButtonElement; const counterSpan = document.querySelector("#counter") as HTMLSpanElement; incrementBtn.addEventListener("click", () => { counter.increment(); update(); }); resetBtn.addEventListener("click", () => { counter.reset(); update(); }); function update(): void { counterSpan.innerText = String(counter.count()); } In questo esempio non ci abbiamo guadagnato molto ad isolare lo stato in un modulo; ma, immaginiamo di voler combinare questa logica molto semplice con altre logiche: se ne starebbero ognuna confinata nel suo modulo, con il nostro script principale che si limita a fare da orchestratore.  A dirla tutta, in praticamente tutti i framework esistenti, noi agiamo quasi esclusivamente a livello di moduli, estendendo le funzionalità del framework che, appunto, fa da orchestratore secondo le sue logiche di funzionamento.  Per questo non dobbiamo preoccuparci particolarmente di capire esattamente come funziona internamente la gestione dei moduli, ma è utile capire bene come funzionano i meccanismi di importazione ed esportazione. Tutto ciò che esportiamo usando export davanti a una definizione di variabili e funzioni (come negli esempi precedenti) può essere importato con le seguenti sintassi: // importa tutto e lo mette in un oggetto 'counter' import * as counter from "./counter"; // importa manualmente le singole exportazioni import { count, increment, reset } from "./counter"; // importa singole esportazioni con un nome diverso import { count as getCount } from "./counter"; Un modulo può anche esportare tipi:  export interface Counter { count: number; } export type Count = number; I tipi possono essere importati come fossero valori, oppure specificando che si tratta di tipi:  // importa tutti i tipi di counter import type * as counter from "./counter"; type CC = counter.Count; // importa un tipo import { type Counter } from "./counter"; L'uso di type per specificare i tipi non è obbligatorio di default, ma può essere richiesto in base alla configurazione di TypeScript in uso. I moduli possono avere un'esportazione di default: let _count = 0; export function count(): number { return _count; } export function increment(): void { _count++; } export function reset(): void { _count = 0; } export interface Counter { count: number; } export default { count, increment, reset }; Le esportazioni di default si possono importare così:  // importa un'esportazione di default import counter from "./counter"; // importa il default più altre esportazioni import counter, { type Count } from "./counter"; Namespace in Typescript Per concludere, vediamo un costrutto che non ha un corrispettivo nel linguaggio JavaScript, che può avere qualche caso di utilizzo quando si vuole simulare il funzionamento di diversi moduli all'interno dello stesso file, oppure, al contrario, quando si vuole avere un modulo che attraversa più file; stiamo parlando dei namespace (o spazi di nomi): namespace Counter { let _count = 0; export function count(): number { return _count; } export function increment(): void { _count++; } export function reset(): void { _count = 0; } export interface Counter { count: number; } export type Count = number; } type CC = Counter.Counter; const { count } = Counter; Counter si comporta come un oggetto in grado di contenere tipi ma anche variabili e funzioni, un po' come se fosse un modulo nel file.  I namespace sono molto potenti e versatili, ma concretamente hanno dei casi d'uso molto ristretti; per questo li menzioniamo senza approfondirli.  A questo punto abbiamo tutti gli strumenti per suddividere il nostro codice in blocchetti autonomi che interagiscono tra loro attraverso esportazioni e importazioni. Ma c'è un limite a quello che abbiamo visto finora, che non abbiamo ancora affrontato: i moduli così definiti e utilizzati, sono statici, cioè esistono in un'unica copia attraverso tutta l'applicazione. Il problema è che, nel mondo reale, potremmo aver bisogno di un modulo che si prenda cura di uno stato alla volta; abbiamo dunque bisogno di introdurre una forma di istanziamento dinamico dei moduli, e questo lo possiamo fare, essenzialmente, con due approcci: quello offerto dalla programmazione ad oggetti e quello offerto dalla programmazione funzionale. Nel primo caso, introdurremo le classi, nel secondo un costrutto che non introduce nulla di nuovo a livello sintattico ma che è altrettanto potente: le closures.
5.3

Typescript e la programmazione orientata agli oggetti

La programmazione orientata agli oggetti (in inglese object-oriented programming, abbreviato OOP), nella sua accezione più classica, punta ad utilizzare le tecniche e i paradigmi della programmazione procedurale in contesti isolati divisi per aree di competenza e comunicanti attraverso l'invocazione di metodi.  Le classi nella programmazione ad oggetti L'OOP implementa questo tipo di organizzazione utilizzando il concetto di classe; una classe è un prototipo per creare oggetti con una struttura predefinita e una serie di funzioni (chiamate metodi della classe) che hanno accesso alle proprietà di tali oggetti.  Quando TypeScript era alle sue prime versioni JavaScript non disponeva di una sintassi dedicata per definire classi, ma ne permetteva comunque la creazione con costrutti alternativi; similarmente a come abbiamo visto per gli enum, TypeScript, ai tempi, compilava la dichiarazione di una classe in un equivalente JavaScript a runtime. Oggi, questo non è più necessario, e il motivo per cui per tutta la guida abbiamo parlato di tipi ma non di classi è che oggi JavaScript le supporta nativamente con la stessa sintassi adottata inizialmente da TypeScript.  Ecco, dunque, come appare la dichiarazione di una classe in JavaScript: // definizione della classe class User { // costruttore della classe constructor(name, age) { // i parametri del costruttore vengono assegnati // alle proprietà della classe this.name = name; this.age = age; } // i metodi operano sulle proprietà della classe toString() { return `User { name: '${this.name}'; age: ${this.age} }` } } Ovviamente, anche nella dichiarazione di classi, TypeScript ci viene in aiuto in molti modi; ne vediamo di seguito alcuni prima di proseguire: interface ToString { toString(): string; } // implementazione di un'interfaccia class User implements ToString { // modificatore d'accesso 'private' // inizializzazione di default private _age: number = 0; // proprietà 'name' dichiarata direttamente nel parametro del costruttore // modificatore readonly sulla proprietà 'name' // annotazioni di tipo constructor(readonly name: string, age: number) { this.age = age; } // getter della proprietà get age(): number { return this._age; } // setter della proprietà set age(value: number) { if (value < 0) throw new Error('invalid age'); this._age = value; } // metodo che implementa l'interfaccia toString(): string { return `User { name: ${this.name}; age: ${this._age}; }` } } In questo esempio, abbiamo introdotto altre sintassi JavaScript native associate alle classi, come l'inizializzazione di default di una proprietà oppure i getter e setter di una proprietà. Oltre a queste aggiunte, abbiamo potuto applicare tutti i concetti già visti sui tipi anche alle classi e, in più, TypeScript ci ha fornito alcune sintassi di cortesia per fare OOP più agevolmente. Da questo esempio notiamo anche che grazie all'approccio OOP, possiamo proteggere la proprietà age garantendo che nessuna istanza di User abbia mai (né in creazione, né successivamente) un'età negativa.  Anche il linguaggio JavaScript, da qualche anno, ha introdotto una nozione di proprietà privata; questa non va definita con il marcatore private, ma semplicemente prefiggendo il nome della proprietà con #. In ogni caso, l'uso dei marcatori private, public, readonly rimane utile in TypeScript per dichiarare le proprietà direttamente come parametri al costruttore, se vogliamo che queste siano passate al momento dell'istanziamento.  Senza la pretesa di voler insegnare tutti i principi di OOP in pochi paragrafi, riprendiamo l'esempio del contatore e vediamo come la OOP ci propone di organizzare il nostro codice: export class Counter { private _count = 0; get count(): number { return this._count; } increment(): void { this._count++; } reset(): void { this._count = 0; } } Il codice che consuma questa classe diventa:  import { Counter } from "./counter"; const incrementBtn = document.querySelector("#increment") as HTMLButtonElement; const resetBtn = document.querySelector("#reset") as HTMLButtonElement; const counterSpan = document.querySelector("#counter") as HTMLSpanElement; const counter = new Counter(); incrementBtn.addEventListener("click", () => { counter.increment(); update(); }); resetBtn.addEventListener("click", () => { counter.reset(); update(); }); function update(): void { counterSpan.innerText = String(counter.count); } Le differenze con il modulo sono, per il momento, pochissime ma importanti:  Counter non viene inizializzata automaticamente all'importazione, ma dev'essere inizializzata usando new; si possono creare infiniti Counter indipendenti.  Data la natura dinamica delle classi, queste si rivelano più adatte dei moduli a implementare logiche che agiscono su uno stato non necessariamente globale, dove lo stato può anche essere costituito da altre classi; in questo caso si parla di composizione in OOP.   Typescript e il polimorfismo Un altro concetto molto utilizzato in OOP è il polimorfismo, cioè la possibilità di un oggetto di rappresentare diversi tipi di dato attraverso un'interfaccia comune sfruttando le gerarchie di tipi. Il polimorfismo può essere in parte associato concettualmente al funzionamento degli union types:  // union type type A = ...; type B = ...; type AB = A | B; // gerarchia di tipi interface AB {} interface A extends AB { ... } interface B extends AB { ... } La principale differenza tra i due approcci sta nel come li si utilizza: in genere, uno union type viene concretizzato attraverso una forma di restringimento e, poi, gestito in base al suo tipo concreto, mentre in OOP il polimorfismo viene usato con lo scopo esattamente opposto, vale a dire individuare ciò che più tipi hanno in comune e ignorare tutte le loro proprietà specifiche.  Il principio per cui si esprime l'interfaccia che si intende consumare su un parametro che potrebbe implementare diverse altre interfacce è chiamato interface segregation principle, ed è uno dei pilastri della OOP. In pratica, si qualifica un oggetto esclusivamente rispetto alla porzione di metodi o proprietà con i quali si intende interagire, in modo tale da lasciare da parte tutto ciò che non serve.  Data la natura delle classi, il modo più facile per discriminare la logica da applicare in base al tipo concreto è la classe stessa. Infatti, la natura stateful (dotata di stato) di una classe e in particolare dei suoi metodi ci permette di invocare tali metodi senza avere alcuna informazione sullo stato della classe né sulle logiche che tali metodi applicheranno. Per questo motivo, in OOP si preferisce implementare la stessa interfaccia su diverse classi e poi chiamare in astratto il metodo dell'interfaccia, anziché individuare un tipo che rappresenti più classi per poi applicare una determinata logica in base al tipo concreto che si sta processando.  Chiariamo questo concetto riprendendo un esempio precedentemente proposto in questa guida:  // stato rappresentato come union type type Status = | "idle" | "loading" | "error" | "success"; // funzione che discrimina lo stato e applica la logica appropriata function logStatusMessage(status: Status): void { switch (status) { case "idle": console.log("nothing happened :|"); return; case "loading": console.log("please wait..."); return; case "error": console.log("ops, something went wrong :("); return; case "success": console.log("we did it! :D"); return; default: // questo caso è teoricamente impossibile throw new Error("Unreachable code"); } } Abbiamo detto che in OOP si tende a impacchettare informazioni e logica e a usare il polimorfismo per discriminare le casistiche; quindi, il nostro esempio diventa:  // interfaccia che esprime la funzione applicabile a diversi stati interface Status { log(): void; } // implementazioni dell'interfaccia stato per stato class IdleStatus implements Status { log(): void { console.log("nothing happened :|"); } } class LoadingStatus implements Status { log(): void { console.log("please wait..."); } } class ErrorStatus implements Status { log(): void { console.log("ops, something went wrong :("); } } class SuccessStatus implements Status { log(): void { console.log("we did it! :D"); } } // il chiamante chiama il metodo dell'interfaccia // ignorandone la casistica concreta function logStatus(status: Status): void { status.log(); } Qui, la funzione logStatus esiste solo per paragone con l'esempio precedente, perché, a tutti gli effetti, non aggiunge assolutamente nulla all'invocazione diretta del metodo log definito dall'interfaccia Status e implementato in tutte le sue concretizzazioni. Prima di proseguire, fermiamoci a riflettere su pro e contro di questo approccio:  pro: aggiungere o rimuovere un tipo di State in questa gerarchia è un'operazione esclusivamente additiva o sottrattiva, cioè non richiede di modificare le altre implementazioni, e questo rende l'intera gerarchia estremamente flessibile;  contro: interfacce e classi sono decisamente più complesse da serializzare di una stringa, quindi, ipotizzando che lo stato provenga da una chiamata HTTP, avremo comunque bisogno di convertire la stringa iniziale in una delle classi-stato, prima di poterne invocare il metodo log;  pro: in OOP ogni tipo si prende cura di sé stesso, quindi è pressoché impossibile "rompere" una classe dall'esterno se questa è scritta bene;  contro: se si volessero implementare nuove logiche sui diversi tipi di stato, bisognerebbe aggiungere un metodo sull'interfaccia e, poi, implementarlo in ognuna delle classi, quindi bisognerebbe modificare il codice in molti punti diversi.  L'ultimo punto è probabilmente il più doloroso e discende proprio dal principio di base dell'OOP, cioè l'idea di legare strutture di dati (cioè tipi) e le funzioni ad esse associate (cioè moduli) in modo che le seconde abbiano accesso esclusivo ai primi; questa architettura, per quanto efficace nel blindare le nostre logiche, rende molto difficile estendere correttamente gerarchie di classi fornite da codice esterno, come ad esempio librerie. Per questo motivo, prima di proseguire, vediamo un modo per estendere il comportamento delle classi in base al loro tipo, usando dunque l'interfaccia comune tra più classi esattamente come se fosse uno union type. Possiamo farlo grazie all'operatore instanceof che riporta a runtime il tipo della classe.  Immaginiamo di voler aggiungere al tipo Status un ulteriore metodo alert che apra un alert del browser in alcuni casi; in questo caso però, la gerarchia dei vari Status concreti proviene da una libreria esterna e, quindi, non possiamo aggiungere un metodo alert direttamente sull'interfaccia e poi implementarlo, ma dobbiamo invece creare una funzione esterna:  function alertStatus(status: Status): void { if (status instanceof ErrorStatus) alert("ops!"); else if (status instanceof SuccessStatus) alert("done!"); } La nostra funzione alertStatus è ancora in grado di implementare diversi comportamenti per diversi sottotipi di Status ma, in questo caso, deve discriminare manualmente il tipo. In questo caso, non tutti i sottotipi hanno un comportamento implementato, nonostante dal parametro della funzione appaia che alertStatus operi su qualsiasi tipo di Status; infatti, sempre in questo caso, è come se per tutti gli stati diversi da errore e successo, il comportamento implementato sia { }, cioè "non fare nulla". Il motivo per cui instanceof funziona a runtime è che le classi non sono un costrutto TypeScript ma JavaScript! Ad esempio, grazie a instanceof, possiamo identificare in JavaScript i tipi Array e Date in esecuzione in qualsiasi momento. 
5.4

Typescript e la programmazione funzionale

Abbiamo visto le tecniche proposte dalla programmazione orientata agli oggetti per organizzare dinamicamente dati e funzioni e discriminare tra diverse casistiche; vediamo, ora, come le stesse problematiche vengono affrontate in programmazione funzionale.  Ripartiamo, dunque, dal nostro solito contatore e vediamo come questo verrebbe gestito in FP. Poiché qui le funzioni sono intese in senso matematico, generalmente queste non operano mutazioni sui dati, ma invece restituiscono il risultato di un calcolo come valore di ritorno. Ad ogni modo, anche in FP esiste un modo per rappresentare e incapsulare uno stato in modo simile a come fatto inizialmente a livello di moduli. In pratica, vedremo prima un approccio stateless o statico, e poi uno stateful o dinamico, più simile ad una architettura OOP.  Per l'approccio stateless, l'idea è di delegare la conservazione dello stato al chiamante e fornire tutte le funzioni che permettono di riassegnare quello stato; il nostro modulo counter.ts diventa dunque: type Factory = () => number; type Operator = (counter: number) => number; export const Counter: Factory = () => 0; export const increment: Operator = counter => counter + 1; In questo esempio, la funzione reset è sparita, perché sarebbe identica a Counter, e questo risulta evidente quando guardiamo al codice chiamante:  import { Counter, increment } from "./counter"; const incrementBtn = document.querySelector("#increment") as HTMLButtonElement; const resetBtn = document.querySelector("#reset") as HTMLButtonElement; const counterSpan = document.querySelector("#counter") as HTMLSpanElement; let counter = Counter(); incrementBtn.addEventListener("click", () => { counter = increment(counter); update(); }); resetBtn.addEventListener("click", () => { counter = Counter(); update(); }); function update(): void { counterSpan.innerText = String(counter); } Osserviamo che:   la natura mutevole dello stato è ora esplicita poiché counter non è più costante e il suo valore viene direttamente riassegnato e letto dal nostro codice chiamante;  le funzioni del modulo counter.ts lavorano sul tipo number esclusivamente restituendo un nuovo risultato, senza mai mutare uno stato;  a parità di parametri, le nostre funzioni restituiscono sempre lo stesso risultato, quindi sono al 100% prevedibili. Questo approccio puro presenta molti benefici in termini di determinismo: una volta scritto e testato, un modulo sarà sempre composto esclusivamente di funzioni pure statiche il cui comportamento non dipende da alcun contesto. Rispetto all'implementazione OOP, questa versione ha una grossa falla: abbiamo esposto la gestione del nostro stato e, ora, qualunque parte del codice potrebbe mutarlo in modi inconsistenti, ad esempio portandolo da 0 a 1000 in una sola operazione, oppure assegnandogli un valore minore di zero. La verità è che questa caratteristica dell'approccio FP è tendenzialmente intenzionale: differentemente dall'OOP, in FP i dati sono sempre informazioni passive, dunque la possibilità di estendere l'insieme di operazioni che si possono effettuare su un dato è considerata un vantaggio, mentre per proteggere il nostro stato da operazioni inconsistenti si tende ad utilizzare il type system, che ha il vantaggio di essere controllato in compilazione e dunque di avvisarci in anticipo di possibili errori.  Ovviamente, questo non significa che non possiamo fare incapsulamento in FP; significa solo che le tecniche utilizzate per ottenerlo sono diverse. Abbiamo detto che anche in FP possiamo adottare un approccio stateful, e il modo per farlo sono le closures (chiusure). In realtà, abbiamo già incontrato un esempio di incapsulamento ottenuto con una closure: il primo esempio di modulo counter.ts nella sezione sui moduli. L'implementazione proposta da quell'esempio aveva il limite di non poter gestire lo stato in modo dinamico, ad esempio supportando l'esistenza di più contatori.  Vediamo come superare questo limite, riscrivendo così il nostro counter.ts: export type Instance = { count: () => number; increment: () => void; reset: () => void; }; export type Factory = () => Instance; export const Counter: Factory = () => { let state = 0; return { count: () => state, increment: () => { state++ }, reset: () => { state = 0 }, }; }; Il motivo per cui il corpo increment e reset è racchiuso tra graffe è che vogliamo esprimere che tali funzioni ritornino void anziché il risultato di state++ (che sarebbe il valore di state prima dell'incremento) o di state = 0 (che sarebbe 0).  Il codice che consuma questo modulo è il seguente:  import { Counter } from "./counter"; const incrementBtn = document.querySelector("#increment") as HTMLButtonElement; const resetBtn = document.querySelector("#reset") as HTMLButtonElement; const counterSpan = document.querySelector("#counter") as HTMLSpanElement; const { count, increment, reset } = Counter(); incrementBtn.addEventListener("click", () => { increment(); update(); }); resetBtn.addEventListener("click", () => { reset(); update(); }); function update(): void { counterSpan.innerText = String(count()); } Notiamo come questo esempio sia la perfetta via di mezzo tra l'esempio del modulo e l'esempio OOP: anche questa volta il risultato di Counter è un oggetto creato dinamicamente, che abbiamo destrutturato nei suoi metodi; questa volta, però, i metodi non mutano delle proprietà di un oggetto come nella classe, bensì una variabile incapsulata nella funzione Counter, che si comporta come se fosse il costruttore di una classe, ma viene chiamata senza l'operatore new. Attenzione: il risultato di Counter() può essere destrutturato come se si trattasse di un modulo statico o di un normale oggetto, mentre nel caso OOP equivalente quest'operazione non si deve mai fare!  C'è una differenza chiave tra il mutare le proprietà di una classe e il mutare una variabile nascosta da una closure: la parola chiave this. I metodi di una classe non sono closures, ma funzioni che utilizzano la variabile speciale this per legarsi all'istanza su cui vengono chiamati.  Destrutturando un'istanza di classe spezziamo quel legame, e facendolo ne rompiamo i metodi. A questo punto, dovremmo anche aver chiarito perché si parla di chiusure o closures: le funzioni restituite dalla funzione Counter sono in grado di catturare la variabile state e continuare a manipolarla anche dopo che la funzione Counter ha concluso la sua esecuzione; in pratica, come per magia, abbiamo prolungato la vita di una variabile al di fuori dello scope in cui è nata, e stiamo usando quello scope catturato come se fosse un oggetto inaccessibile, se non attraverso le funzioni restituite da quello scope.  In questo modo abbiamo reso il nostro Counter perfettamente incapsulato e dunque "inestensibile", nel senso che il suo stato non sarà in alcun modo manipolabile, se non secondo le operazioni fornite dal suo "costruttore". NB. In realtà, in FP, non si parla tanto di costruttori ma di factory cioè fabbriche. La differenza è che un costruttore inizializza le proprietà di una classe in modo procedurale, mentre una factory restituisce un oggetto o un'istanza di un qualcosa. In generale, una factory potrebbe anche restituire l'istanza di una classe, nascondendone l'istanziamento.  Le factory presentano un grande vantaggio rispetto ai costruttori: possono restituire più di un tipo, ad esempio null se i valori passati per l'inizializzazione non sono validi; un costruttore può solo limitarsi a scatenare un errore, interrompendo l'esecuzione del programma. Come abbiamo visto, le closures presentano moltissime sovrapposizioni con le classi; in ogni caso, in FP si preferisce generalmente adottare un approccio stateless e spostare la mutazione dei dati al di fuori di moduli di calcolo. Non solo questo li rende più testabili, ma anche meno inclini a errori dovuti alla condivisione dello stato tra diverse aree del codice.    Il branding dei tipi in Typescript Prima di avviarci a concludere questa guida, vediamo un'ultima tecnica particolarmente diffusa in TypeScript, che permette di qualificare a livello di type system un dato senza alterarne il reale contenuto a runtime: il branding (marchiatura) dei tipi. Con i branded types si ottiene una forma di validazione a compile tyme sfruttando le capacità del type checker, evitando, così, di ricorrere agli errori e, dunque, di alterare il normale flusso di esecuzione di una funzione.  Riprendiamo l'esempio della classe User vista in precedenza: attraverso il setter della proprietà age, questa classe si assicura che l'età di un utente non sia mai impostata con un valore negativo. Questo significa che un blocco di codice chiamante potrebbe causare un errore a runtime assegnando il valore sbagliato.  In FP l'utilizzo di throw non è molto apprezzato, perché comporta una variazione del normale flusso di input/output che caratterizza le funzioni pure, e si comporta più come un effetto collaterale. Quello che invece si tende a fare, è introdurre il vincolo a livello di type system.  Dunque proviamo a scrivere in FP una factory di User avvalendoci di un branded type per l'età, che rappresenta solo numeri interi maggiori di zero; il nostro modulo user.ts avrà questo aspetto: // TypeScript accetta l'intersezione tra 'number' e un object literal // allo scopo esplicito di abilitare la creazione di un branded type. type NonZeroInteger = number & { __kind: "non_zero_integer" }; // Ora il vincolo non è più nei metodi della classe ma nel tipo. type User = { name: string; age: NonZeroInteger; }; // L'unico modo valido per creare un oggetto di tipo User // è passare da questa funzione che marchia un numero come // intero e maggiore di zero. const NonZeroInteger = (x: number): NonZeroInteger | null => Number.isInteger(x) && x > 0 ? x as NonZeroInteger : null; Vediamo come si comporta il codice che consuma questo modulo:  const invalid: User = { name: "John", age: 32 }; // Type 'number' is not assignable to type 'NonZeroInteger'. // Ora il codice chiamante è costretto a fare una verifica const age = NonZeroInteger(32); const stillInvalid: User = { name: "John", age }; // Type 'NonZeroInteger | null' is not assignable to type 'NonZeroInteger'. if (age !== null) { const valid: User = { name: "John", age }; // OK } In pratica, anziché risolvere a never l'intersezione tra number e un object literal, TypeScript lascia aperta la possibilità consentendoci di marchiare un numero con una proprietà che di fatto non esiste se non in compilazione, e dunque solo per il tempo necessario ad effettuare i type check prima di sparire nel codice JavaScript risultante. Piuttosto ingegnoso!  Ovviamente, il concetto di marchiatura dei tipi può essere generalizzato; facciamolo con un modulo branded.ts: export type Branded<T, B extends string> = T & { __brand: B }; export const brand = <T, B extends string>(value: T, _brand: B): Branded<T, B> => value as Branded<T, B> In questo modulo, il tipo Branded rappresenta un qualunque tipo brandizzato con una stringa; qui la stringa è rappresentata dal parametro di tipo B; la funzione brand marchia il tipo di una qualunque variabile usando la stringa fornita dal parametro _brand; notare che questo parametro non viene mai realmente utilizzato nel corpo della funzione, ma ha il solo scopo di permettere al type checker di marchiare value.  Questo semplicissimo modulo contiene lo stretto indispensabile per brandizzare comodamente qualunque cosa. Ovviamente ha senso solo se usato insieme ad una funzione che converta un tipo concreto nella sua controparte marchiata dopo aver verificato che i vincoli rappresentati dal marchio siano rispettati.  Ad ogni modo, questo approccio presenta ancora un problema:  import { brand } from "./branded"; const brandedFive = brand(5, "five"); console.log(brandedFive.__brand); // OK In questo codice c'è una falla: la proprietà __brand non esiste davvero su brandedFive (che non è altro che un 5), eppure TypeScript la accetta, condannandoci ad un TypeError garantito in esecuzione! Per risolvere questo problema possiamo ricorrere ad uno stratagemma speciale, che coinvolge due concetti avanzati mai affrontati in precedenza in questa guida: i simboli e la parola chiave declare. Un symbol è un tipo primitivo speciale che si usa nel linguaggio JavaScript per creare delle chiavi uniche sugli oggetti. Le proprietà create con un symbol possono essere lette solo da chi possiede un riferimento a tale simbolo, conservato in una variabile; quindi, non avremo modo di navigare alla proprietà che identifica il marchio del nostro tipo e questo resterà utilizzabile solo in termini di type check.  Con la keyword declare si può istruire TypeScript sull'esistenza di una variabile in un dato contesto, senza che in quel contesto la v ariabile compaia effettivamente; è grazie a declare che TypeScript conosce tutte le API JavaScript del DOM, ad esempio, che sono fornite dal browser senza essere mai dichiarate esplicitamente in alcun codice JavaScript.  Vediamo, dunque, come diventa il nostro modulo branded.ts:  // Questo 'unique symbol' non esiste davvero, se non a compile time! declare const __brand: unique symbol; // La proprietà identificata dalla chiave [__brand] è anch'essa inesistente, // ma a differenza della sua controparte stringa, questa è anche inaccessibile. export type Branded<T, B extends string> = T & { [__brand]: B }; export const brand = <T, B extends string>(value: T, _brand: B): Branded<T, B> => value as Branded<T, B>; Tutto il resto è uguale, eccetto una cosa:  import { brand } from "./branded"; const brandedFive = brand(5, "five"); console.log(brandedFive.__brand); // Property '__brand' does not exist on type 'Branded<5, "five">'. Ora TypeScript non è in grado di identificare alcuna proprietà che rappresenti il marchio della nostra variabile, quindi siamo protetti da ogni errore. I tipi marchiati sono un argomento che coinvolge molti concetti complessi, e sono riportati in questa guida esclusivamente per due ragioni: la prima è semplice amor di completezza; la seconda è per rappresentare senza sconti l'ingegnosità e l'astrazione su cui si tende a operare quando si fa programmazione funzionale. Questo non deve, tuttavia, spaventare: si tratta semplicemente di un approccio più formale e rigoroso, fortemente ispirato alla matematica; ci sono diverse librerie che implementano le tecniche mostrate, e possiamo adottarle senza preoccuparci più di tanto di come siano implementate, purché ci sia chiaro il loro scopo.

Sei indeciso sul percorso? 💭

Parliamone! Scrivici su Whatsapp e risponderemo a tutte le tue domande per capire quale dei nostri corsi è il più adatto alle tue esigenze.

Oppure chiamaci al 800 128 626