
Series: TypeScript
Introduzione
La capacità di TypeScript di rifinire un tipo analizzando il control flow del codice è una delle feature più comode e utilizzate del linguaggio. Vediamo un semplice esempio per chiarire di che cosa si tratta:
function foo(x: string | number) {
if(typeof x === "string") {
console.log(x.repeat(x.length));
} else {
console.log(Math.sqrt(x));
}
}
TypeScript è in grado di comprendere che se la condizione del costrutto if
è verificata il tipo di x
può essere correttamente “ristretto” al solo string
: ecco che diventa possibile accedere al metodo repeat
e alla proprietà length
della stringa. Ciò ha come immediata conseguenza il fatto che nel ramo else
la variabile x
può solamente essere un number
, quindi è possibile darlo in input a una delle utils esposte da Math
.
Il problema
TypeScript non supporta il narrowing di un type parameter T
in base, ad esempio, al valore contenuto in una variabile avente quel tipo. In altre parole l’analisi del control flow va quasi completamente a farsi benedire. Non dal papa, da Hejlsberg in persona. Questo perché è facilissimo ricadere in una situazione nella quale tale narrowing sarebbe scorretto, o unsound, come dicono gli inglesi.
Ecco che incontriamo dei problemi in situazioni come la seguente:
interface Payloads {
auth: {
username: string;
password: string;
};
cart: {
items: { id: string; quantity: number }[];
price: number;
appliedCoupon?: string;
};
room: {
id: string;
name: string;
partecipants: { username: string }[];
};
}
function createPayload<K extends keyof Payloads>(service: K): Payloads[K] {
switch (service) {
case "auth":
return {
username: "johndoe",
password: "eodnhoj",
};
case "cart":
return {
items: [],
price: 0,
};
case "room":
return {
id: "123",
name: "kitchen",
partecipants: [{ username: "johndoe" }],
};
}
}
La funzione createPayload
evidentemente copre ogni caso restituendo di volta in volta il payload corretto, eppure TypeScript (v. 4.6.2) non è d’accordo e ci segnala due diverse tipologie di errore.
La prima riguarda il tipo di ritorno della funzione: “Function lacks ending return statement and return type does not include ‘undefined’.”. In altre parole TypeScript non considera esaustivo lo switch
/case
e si aspetta quindi che venga gestito nel codice anche il default
case, sebbene non potrà mai accadere che ll parametro service
risulti diverso dai valori "auth"
, "cart"
e "room"
.
Possiamo facilmente risolvere l’errore nel seguente modo:
function createPayload<K extends keyof Payloads>(service: K): Payloads[K] {
switch (service) {
case "auth":
return {
username: "johndoe",
password: "eodnhoj",
};
case "cart":
return {
items: [],
price: 0,
};
case "room":
return {
id: "123",
name: "kitchen",
partecipants: [{ username: "johndoe" }],
};
default:
throw new Error("undefined service");
}
}
Il secondo tipo di errore invece risulta molto più prolisso e oscuro, e intacca ogni ramo dello switch
. TypeScript ci dice che ogni valore restituito “is not assignable to type ‘Payloads[K]'”, in particolare ci dice che non è assegnabile al tipo:
{
username: string;
password: string;
} & {
items: { id: string; quantity: number }[];
price: number;
appliedCoupon?: string | undefined;
} & {
id: string;
name: string;
partecipants: { username: string }[];
};
Perché? Da dove saltano fuori quelle intersezioni? Cerchiamo di capire meglio cosa succede.
Abbiamo detto che TypeScript non supporta l’analisi del control flow per rifinire un tipo parametrico, e K
è proprio questo! TypeScript sa che service
può essere ristretta, ad esempio, al solo caso "auth'
, perciò ci permette di creare uno switch
/case
come quello presente in createPayload
, ma non rifinisce di conseguenza anche il type parameter K
. Perciò non sa che { username: "johndoe", password: "eodnhoj" }
è assegnabile a Payloads[K]
in quella specifica circostanza.
TypeScript è essenzialmente fin troppo cauto, e ci richiede di restituire un valore che per ogni possibile K
è assegnabile a Payloads[K]
. Tale valore dovrà quindi contenere ogni possibile proprietà disponibile accedendo ad una qualsiasi chiave di un oggetto Payloads
, perciò il suo tipo non può che essere l’intersezione tra tutti i vari tipi delle chiavi presenti in Payloads
. Nessun ramo dello switch
/case
restituisce un valore di tale tipo, quindi TypeScript protesta in ognuno.
La soluzione
Come possiamo creare un valore di tipo Payloads[K]
in modo pulito? Siamo costretti a restituire sempre un mappazzone con millemila proprietà? Per fortuna no!
Ragioniamo sul significato di tale tipo: esso deriva dall’accedere ad un oggetto di tipo Payloads
con una chiave generica K
, chiave che deve essere assegnabile a keyof Payloads
. Abbiamo già questa chiave, è proprio il parametro service
! Ci manca giusto l’oggetto Payloads
:
function createPayload<K extends keyof Payloads>(service: K): Payloads[K] {
const payloads: Payloads = {
auth: {
username: "johndoe",
password: "eodnhoj",
},
cart: {
items: [],
price: 0,
},
room: {
id: "123",
name: "kitchen",
partecipants: [{ username: "johndoe" }],
},
};
return payloads[service];
}
Ecco che TypeScript è pienamente soddisfatto perché stiamo restituendo un valore assegnabile per ogni possibile K
a Payloads[K]
. Oltre a ciò non è nemmeno più necessario gestire inutilmente un default
case sporcando il nostro codice.