Series: TypeScript
Introduzione Link to heading
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 Link to heading
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. 5.5.3
) 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
. TypeScript non sa che { username: "johndoe", password: "eodnhoj" }
è assegnabile a Payloads[K]
in quella specifica circostanza poiché non tiene conto dell’uguaglianza K = "auth"
.
A causa di questa skill issue TypeScript preferisce essere cauto richiedendoci di restituire un valore che per ogni possibile K
sia 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 di essi.
La soluzione Link to heading
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 una chiave di tipo K
? Si, è proprio il parametro service
! Ci manca giusto l’oggetto Payloads
:
function createPayload<K extends keyof Payloads>(service: K): Payloads[K] {
const payloads = {
auth: {
username: "johndoe",
password: "eodnhoj",
},
cart: {
items: [],
price: 0,
},
room: {
id: "123",
name: "kitchen",
partecipants: [{ username: "johndoe" }],
},
} satisfies Payloads;
return payloads[service];
}
Ecco che TypeScript è pienamente soddisfatto perché stiamo restituendo esattamente il Payloads[K]
richiesto. Oltre a ciò non è nemmeno più necessario gestire inutilmente un default
case sporcando il nostro codice.
La soluzione efficiente Link to heading
La soluzione appena proposta è sicuramente corretta, ma non è la più efficiente. Ogni volta che chiamiamo createPayload
stiamo creando un oggetto Payloads
completo, anche se ci serve solamente una piccola parte di esso. Questo potrebbe diventare un problema se Payloads
fosse un oggetto molto grande e complesso e la funzione createPayload
venisse chiamata con una certa frequenza.
Per risolvere questo problema possiamo sfruttare i getters:
function createPayload<K extends keyof Payloads>(service: K): Payloads[K] {
const payloads = {
get auth() {
return {
username: "johndoe",
password: "eodnhoj",
}
},
get cart() {
return {
items: [],
price: 0,
}
},
get room() {
return {
id: "123",
name: "kitchen",
partecipants: [{ username: "johndoe" }],
}
},
} satisfies Payloads;
return payloads[service];
}