I vincoli sui generics in TypeScript

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.

Limitare un generic con extends

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); // Errore

Significato della sintassi extends

Quando 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.

Perché utilizzare i vincoli

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:

  • number
  • boolean
  • Date
  • object

Questi 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.

Vincolare un generic a un tipo esistente

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.

L'operatore keyof

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.

Utilizzare keyof con i generics

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"); // Errore

L'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");

Prova!Completa gli spazi vuoti con il testo appropriato.
// Imposta un vincolo sul parametro generico
function stampaPeso<T { peso: }>(oggetto: T): void {
    console.log(oggetto.peso);
}

stampaPeso({ peso: 10 });

Prova! Scegli l'opzione corretta tra quelle elencate.
function ottieniValore<T, K extends _______ T>(modello: T, chiave: K): T[K] {
    return modello[chiave];
}