I generics permettono di creare funzioni e strutture riutilizzabili con tipi diversi, ma in alcuni casi non vogliamo accettare qualsiasi tipo.
Supponiamo, per esempio, di voler creare una funzione che utilizzi una proprietà specifica di un oggetto. Se il parametro è semplicemente di tipo T, TypeScript non può sapere quali proprietà siano disponibili.
I vincoli sui generics permettono di limitare i tipi che possono essere utilizzati come parametri generici. Per farlo utilizziamo principalmente la keyword extends e, quando lavoriamo con le proprietà degli oggetti, l'operatore keyof.
La keyword extends permette di stabilire un vincolo sul parametro generico.
Per esempio, possiamo scrivere:
function stampaLunghezza<T extends { length: number }>(valore: T): void {
console.log(valore.length);
}In questo caso T non può essere un tipo qualsiasi.
Deve essere un tipo che possiede una proprietà length: number. Possiamo quindi utilizzare la funzione con una stringa:
stampaLunghezza("Ciao");Le stringhe possiedono infatti la proprietà length.
Possiamo utilizzarla anche con un array (in quanto anche gli array possiedono la proprietà length):
stampaLunghezza([10, 20, 30]);Non possiamo invece passare un semplice numero, in quanto un number non possiede la proprietà length:
stampaLunghezza(100); // ErroreQuando utilizziamo extends nei generics, non stiamo necessariamente parlando di ereditarietà tra classi, come invece abbiamo visto per le classi astratte.
Utilizzando <T extends { length: number }> dell'esempio fatto precedentemente, extends significa che il tipo utilizzato per T deve essere compatibile con il tipo indicato come vincolo.
In poche parole, T può essere qualsiasi tipo che possiede una proprietà length di tipo number. Questo permette di mantenere la flessibilità dei generics senza rinunciare ai controlli di tipo.
Consideriamo una funzione generica senza vincoli:
function stampaLunghezza<T>(valore: T): void {
console.log(valore.length);
}TypeScript segnala un errore perché non può sapere se T possiede length. Il generic potrebbe infatti essere:
numberbooleanDateobjectQuesti tipi non hanno necessariamente una proprietà length.
Possiamo risolvere il problema specificando un vincolo:
function stampaLunghezza<T extends { length: number }>(valore: T): void {
console.log(valore.length);
}Ora TypeScript sa che qualsiasi T utilizzato dalla funzione deve possedere length.
Il vincolo non deve necessariamente essere un tipo primitivo.
Possiamo utilizzare un'interfaccia:
interface ConNome {
nome: string;
}Ora possiamo utilizzare l'interfaccia come vincolo:
function stampaNome<T extends ConNome>(persona: T): void {
console.log(persona.nome);
}Possiamo passare un oggetto che possiede nome:
stampaNome({
nome: "Marco"
});Possiamo anche passare un oggetto con proprietà aggiuntive:
stampaNome({
nome: "Andrea",
eta: 30
});Questo funziona perché l'oggetto possiede comunque la proprietà richiesta dal vincolo.
Quando vogliamo lavorare con le proprietà di un oggetto, possiamo utilizzare l'operatore keyof.
Possiamo inizialmente dichiarare un'interfaccia:
interface Utente {
nome: string;
eta: number;
email: string;
}Ora possiamo ottenere l'unione delle sue chiavi con keyof Utente. Il risultato è equivalente a "nome" | "eta" | "email".
Quindi keyof permette di ottenere l'insieme delle proprietà disponibili in un tipo.
Possiamo utilizzare keyof per creare una funzione che accetta soltanto le proprietà realmente esistenti in un oggetto.
Ad esempio:
function leggiProprieta<T, K extends keyof T>(
oggetto: T,
chiave: K
) {
return oggetto[chiave];
}Nell'esempio, la parte K extends keyof T impone un vincolo molto preciso: K deve essere una delle chiavi di T.
Il vantaggio principale di questo approccio è che TypeScript impedisce di utilizzare una chiave che non appartiene all'oggetto. Ad esempio:
leggiProprieta(utente, "indirizzo"); // ErroreL'esempio genera un errore perché "indirizzo" non è una chiave di utente. Senza il vincolo, questo controllo andrebbe perso.
Un altro vantaggio è che TypeScript può determinare automaticamente il tipo restituito in base alla chiave utilizzata (abbiamo già approfondito nella lezione su come vengono definiti i tipi di valori in TypeScript).
Ad esempio, qui nome è una string:
const nome = leggiProprieta(utente, "nome");