Ogni programmatore JavaScript, sia frontend che backend, avrà sicuramente provato sulla propria pelle i problemi di sviluppare senza un type checker. Quante volte avrete visto un errore di questo tipo?
Uncaught TypeError: Cannot read property 'value' of undefined
Questo criptico messaggio non ci sta dicendo altro che quella variabile non punta ad un oggetto ma a undefined
. Trovare la causa di questi errori non è troppo difficile se il progetto è di piccole dimensioni, ma diventa esponenzialmente più complicato man mano che il progetto cresce.
Per risolvere tutti questi problemi, fortunatamente, esiste un tool molto potente, TypeScript. TypeScript è un superset di JavaScript che consente di aggiungere annotazioni di tipo al codice, fornendo strumenti di sviluppo che consentono di:
- Verificare la correttezza dei tipi, come ad esempio:
- Accesso ai valori nulli
- Campi mancanti su oggetti
- Edge case non implementati
- Autocompletamento del codice in tempo reale in base ai tipi
- Forte integrazione con IDE come Visual Studio Code
- Questo consente di avere documentazione sempre aggiornata e corretta, visto che deriva direttamente dai tipi nel codice
In questo articolo, però, non vi parleremo del linguaggio nello specifico, dato che possiede già una eccellente documentazione e molte guide dettagliate. Per dare veramente valore a questo strumento vorremmo invece raccontare la nostra esperienza, ossia, dove TypeScript ci è stato d'aiuto per risolvere determinati problemi.
🧨 1. Accesso ai campi nulli
Una prima esperienza viene direttamente dal nostro Blog, nella gestione delle lingue.
Quando un utente visita il blog, viene salvata la lingua preferita in Local Storage. Nel momento in cui l'utente visita di nuovo la root, viene automaticamente reindirizzato alla lingua corretta, grazie ad una tabella che tiene la corrispondenza fra lingua e url di destinazione.
Questa implementazione poco attenta potrebbe funzionare, fino a quando però una lingua non viene cambiata/rimossa. Osservando molto attentamente il codice si trova l'errore:
Leggendo il valore baseUrl
della lingua, se la chiave languageKey
non è presente in languages verrà letto un campo undefined
. Questo causa una eccezione TypeError: languages[languageKey] is undefined
, che farà crashare React e l'intera applicazione.
Se proviamo a far analizzare il codice a TypeScript, tenendolo as-is senza aggiungere nessuna annotazione di tipo, ci verrà presentato il seguente errore:
Typescript è stato in grado di trovare l'accesso pericoloso senza bisogno di nessuna annotazione aggiuntiva, salvandoci da un crash (provare per credere sul Playground).
Come abbiamo detto all'inizio, questo specifico caso viene dal nostro blog, che è stato scritto in plain JavaScript. Se avessimo utilizzato TypeScript fin dall'inizio ci saremmo accorti ben prima di questo bug, addirittura durante lo sviluppo, e non dopo giorni che il blog era in produzione. TypeScript quindi si dimostra molto utile per evitare questa intera classe di errori, con un effort minimo di annotazione dei tipi, riducendo di molto il tempo necessario per il testing.
Poter verificare gli accessi a campi nulli rende TypeScript ancora più potente e sicuro di altri compilatori, come ad esempio Java, dove la gestione dei null
viene addirittura definita il "million dollar mistake".
⏱ 2. Tipizzare valori numerici: gestire le unità di Tempo
Un altro tra i problemi più complicati da gestire quando si sviluppano applicazioni è sicuramente la gestione del tempo. Gestire durate, timestamp e timezone richiede molta attenzione, altrimenti può portare facilmente ad errori off-by-one o nelle unità di misura.
Immaginiamo di dover gestire con un'applicazione web gli orari dei dipendenti. Riceviamo da un servizio le informazioni di un dipendente, comprese le sue ore lavorative per contratto, salvate come minuti in formato JSON.
const employee = {
name: "John Doe", // string
shiftHours: {
monday: 60, // number
tuesday: 120,
wednesday: 240,
thursday: 180,
friday: 60,
saturday: 120,
sunday: 0,
},
};
In questo caso, TypeScript interpeta il tipo dei valori di shiftHours
come number
. Questo potrebbe sembrare innocuo, ma non ci permette di verificare il fatto che sono codificati come minuti, non come ore. A meno di scriverlo nella documentazione, un altro sviluppatore potrebbe facilmente sbagliarsi e scambiare l'unità di misura, interpretando il numero come ore.
function HourDisplay({ hours }: { hours: number }) {
return <>{hours} hours</>
}
function EmployeeDisplay({ employee }: { employee: Employee }) {
return (
<>
<h2>Name: {employee.name}</h2>
{Object.entries(employee.shiftHours).map(([day, time]) => (
<p key={day}>
{/* Stiamo stampando i minuti come fossero ore… whoops! */}
{day}: <HourDisplay hours={time} />
</p>
))}
</>
);
}
Su questo componente React per visualizzare i dati di un dipendente non avremo malfunzionamenti o eccezioni in runtime, e infatti TypeScript lo considera come corretto.
Il problema di questa implementazione è che stiamo sussumendo (una brutta parola che vale la pena imparare!) tutto a number
. I campi di tipo number sono del tutto generici, e di conseguenza il compilatore non fa enforcing delle unità di misura. Possiamo però grazie a TypeScript evitare questo tipo di errori logici utilizzando un pattern chiamato Nominal Typing. Questa tecnica prevede l'aggiunta di annotazioni di tipo più stringenti a tipi base, come numeri o stringhe, permettendo di fare enforcing anche sul loro contenuto.
Una prima soluzione potrebbe essere utilizzare un type alias. Ci accorgiamo, però, che non funziona come ci aspetteremmo:
type Hours = number;
type Minutes = number;
function HourDisplay({ hours }: { hours: Hours }) {
return <>{hours} hours</>;
}
function App() {
const test: Minutes = 60;
// Nessun errore! 😕
return <HourDisplay hours={test} />;
}
In questo modo uno sviluppatore che chiamerà il componente HourDisplay
non noterà nemmeno nell'autocompletamento che il tipo atteso è in ore (Hours
), visto che vengono tolti gli alias per maggiore chiarezza.
Il codice, soprattutto, compila senza problemi, dal momento che TypeScript utilizza Structural Typing e non Nominal Typing. Questo vuol dire che per controllare se due tipi combaciano si basa non sul loro nome, ma sul loro contenuto. Questo comportamento può sembrare strano se uno sviluppatore è abituato a linguaggi come Java o C#, deriva infatti dai linguaggi funzionali come Haskell o OCaml.
Per risolvere questo problema e controllare i tipi in maniera nominale ci sono molti approcci possibili, sia con controlli a runtime, che solamente a compile-time. Tutti questi pattern comunque si basano sull'aggiungere un campo tag in un oggetto che faccia da discriminante.
La soluzione più standard, utilizzata dagli stessi sviluppatori di TypeScript, è la seguente, basata su un tag di tipo void
presente solo a compile-time:
interface Hours {
_hoursBrand: void,
value: number;
}
interface Minutes {
_minutesBrand: void;
value: number;
}
function HourDisplay({ hours }: { hours: Hours }) {
return <>{hours.value} hours</>
}
function App() {
// Type assertion
const test = { value: 60 } as Minutes;
// Questa riga darà un errore, come ci aspettiamo
return <HourDisplay hours={test} />;
}
Un pattern come questo diventa molto utile non solo quando si gestiscono unità di tempo, ma anche in molti altri casi. Abbiamo ad esempio usato questa tecnica su alcuni progetti per gestire identificatori multipli, aggiungendo tipi nominali alle stringhe. Ciò ci ha consentito di evitare moltissimi errori data la complessità del dominio.
Per maggiori informazioni sulle tecniche di Nominal typing, consiglio la lettura di questo articolo e di questa libreria.
3. 📌 Array a dimensione costante: Coordinate XYZ
In SMC amiamo la grafica 3D, soprattutto sul Web. Il problema è che, usando diverse librerie grafiche come Three.JS e react-three-fiber, non tutte gestiscono le coordinate allo stesso modo.
Mentre per alcune sono oggetti con campi x, y e z, in altre librerie troviamo le coordinate rappresentate come array a tre elementi:
// Variante con oggetti (THREE.Vector3)
const p1 = { x: 2, y: 3, z: 4 };
// Variante con Array
const p2 = [2, 3, 4];
Nel momento in cui alcune funzioni si aspettano array come parametri e altre oggetti è molto facile sbagliarsi, producendo errori in console dalla non immediata comprensione:
// Abbiano i nostri punti, provenienti magari da un servizio
const p1 = new THREE.Vector2(25, 25);
const p2 = new THREE.Vector2(0, 15);
// Questo usa oggetti
const curve = new THREE.SplineCurve([p1, p2]);
// Questo usa array
var myShape = new THREE.Shape();
myShape.moveTo(p1.x, p1.y);
myShape.lineTo(p2.x, p2.y);
myShape.getLength(); // 26.925824035672584
// se invece passiamo oggetti...
const myShape2 = new THREE.Shape();
myShape2.moveTo(p1);
myShape2.lineTo(p2);
myShape2.getLength(); // NaN
// What? 😳 🤯
Fortunatamente TypeScript è stato pensato apposta per risolvere questo tipo di problemi, e semplicemente attivandolo sul codice trova immediatamente l'errore. Questo perchè Three.JS ha i typings che consentono di verificare la correttezza dei parametri delle funzioni.
La faccenda si fa più complicata, però, quando vogliamo utilizzare gli array per rappresentare coordinate. Mentre TypeScript può facilmente verificare la presenza dei campi x
, y
e z
su un oggetto, quando accediamo ad un array con la notazione arr[N]
rischiamo di leggere valori non presenti.
TypeScript, infatti, non effettua bound checking sugli array, dato che dipende da informazioni a run-time. Tutti gli array rappresentati come T[]
non hanno bound checking, e gli array literal sono interpretati di default in questo modo.
Possiamo ottenere un controllo migliore grazie al Type Narrowing, ossia forzare un tipo più stretto:
const pointA = [12, 3, 8]; // number[]
// Nessun bound checking 😕
console.log(pointA[4]); // undefined
// Forziamo (narrowing) il tipo ad un array composto da tre number
const pointB: [number, number, number] = [12, 3, 8];
// Ci viene segnalato l'errore 🥳
console.log(pointB[4]);
// Per evitare di scrivere più volte lo stesso tipo, possiamo anche utilizzare un alias
type Point = [number, number, number];
const pointC: Point = [12, 3, 8];
Come abbiamo specificato il tipo di un array composto da N elementi uguali, nulla ci impedisce di fare lo stesso anche con tipi diversi:
// Possiamo utilizzare anche tipi diversi, andando a formare tuple
const tuple: [number, number, string] = [12, 3, "test"];
// tuple[0] è un numero, tutto apposto
console.log(tuple[0] - 5);
// tuple[2] è una stringa, e di conseguenza otteniamo un errore
console.log(tuple[2] - 5);
Se però i dati che andiamo a trattare non sono costanti, ma ad esempio provengono da un servizio o da un file, non possiamo effettuare un cast usando as
, dato che sarebbe pericoloso: non sappiamo che dati possono arrivarci. Per ottenere ancora più sicurezza possiamo combinare un controllo a runtime con una Type Guard:
type Point = [number, number, number];
// La funzione ritorna true se il parametro è un Point
function isValidPoint(arr: any[]): arr is Point {
return arr.length === 3 && arr.every((k) => !isNaN(k));
}
const point = [2, "a"];
if (isValidPoint(point)) {
// Il valore è valido, possiamo accederci
console.log(point[2] - 3);
} else {
// Il valore non è valido, e questo linea darà errore 🤯
console.log(point[2] - 3);
}
Conclusioni
Abbiamo visto come TypeScript può diventare un valido alleato per scrivere codice sicuro e corretto, minimizzando gli errori sugli edge case e il tempo necessario per i test.
Alcuni potrebbero controbattere che tutto ciò che abbiamo mostrato poteva essere risolto con un adeguato testing o attenzione nello sviluppo. In SMC, però, pensiamo che il valore che porta TypeScript sia proprio questo: pagando con un po' di verbosità in piu' diminuisce il carico cognitivo che gli sviluppatori devono portare quando scrivono codice. I tipi fungono da riferimento e aiuto grazie agli IDE, non dovendo più cercare i parametri delle funzioni nella documentazione (che spesso non è neppure aggiornata).
In SMC abbiamo applicato l'uso di TypeScript a tutti i progetti complessi e mission-critical, ottenendo una diminuzione del tempo necessario per i bugfix, dato che in fase di test vengono riscontrati molti meno bug.