Comprendre les génériques TypeScript
Les génériques sont la fonctionnalité qui fait trébucher juste après qu'on s'est senti à l'aise avec les types TypeScript de base, puis qui, une fois le déclic venu, devient l'un des outils les plus utilisés de la boîte. L'idée centrale est modeste : au lieu d'écrire une fonction ou un type pour un type précis, on l'écrit pour « le type qui sera passé », et TypeScript suit ce type pour vous dans le reste de la signature.
Le problème que résolvent les génériques
Supposons que vous vouliez une fonction qui renvoie le premier élément d'un tableau.
function firstOfNumbers(arr: number[]): number {
return arr[0];
}
function firstOfStrings(arr: string[]): string {
return arr[0];
}
Deux fonctions, une logique identique, des types différents. On pourrait la typer avec any :
function first(arr: any[]): any {
return arr[0];
}
Cela marche, mais vous avez jeté toute l'information. first([1, 2, 3]) est désormais de type any au lieu de number, donc rien en aval ne détecte une erreur comme first([1, 2, 3]).toUpperCase().
Les génériques conservent le lien entre le type d'entrée et le type de sortie :
function first<T>(arr: T[]): T {
return arr[0];
}
const n = first([1, 2, 3]); // T inferred as number, n: number
const s = first(['a', 'b', 'c']); // T inferred as string, s: string
T est un paramètre de type : un espace réservé que TypeScript remplit selon ce que vous passez réellement, comme un paramètre de fonction est rempli par une valeur. Vous n'avez pas eu à écrire first<number>([1, 2, 3]) : TypeScript infère T à partir de l'argument.
Contraindre ce que T peut être
Un T sans contrainte peut être n'importe quoi, ce qui signifie qu'on ne peut pas supposer une forme particulière. Ceci ne compilera pas :
function getLength<T>(item: T): number {
return item.length; // Error: Property 'length' does not exist on type 'T'
}
TypeScript a raison de se plaindre : T pourrait être number, qui n'a pas de .length. Contraignez-le avec extends :
function getLength<T extends { length: number }>(item: T): number {
return item.length; // OK — T is guaranteed to have a `length` property
}
getLength('hello'); // OK, strings have .length
getLength([1, 2, 3]); // OK, arrays have .length
getLength(42); // Error: number doesn't satisfy the constraint
Ici, extends ne désigne pas l'héritage de classe : cela signifie « T doit être assignable à cette forme ». Tout type ayant une propriété numérique .length la satisfait.
Interfaces et classes génériques
Les génériques ne servent pas qu'aux fonctions : ils paramètrent les types eux-mêmes. Un Box<T> qui peut contenir n'importe quelle valeur unique :
interface Box<T> {
value: T;
}
const numberBox: Box<number> = { value: 42 };
const stringBox: Box<string> = { value: 'hello' };
Un exemple plus réaliste : un wrapper de réponse d'API typé, utilisé dans toute une application :
interface ApiResponse<T> {
data: T;
status: number;
error: string | null;
}
async function fetchUser(id: string): Promise<ApiResponse<User>> {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
return { data, status: res.status, error: null };
}
Tout appelant de fetchUser obtient response.data typé User, et non any : le générique transporte le type de charge utile spécifique à travers toute la forme de la réponse, sans qu'il faille écrire un UserApiResponse, un PostApiResponse, etc. pour chaque entité.
Plusieurs paramètres de type
Les génériques peuvent prendre plus d'un paramètre, ce qui est courant pour les fonctions qui combinent deux éléments indépendants : la fonction map en est l'exemple canonique :
function mapArray<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
const lengths = mapArray(['a', 'bb', 'ccc'], (s) => s.length);
// T inferred as string, U inferred as number
// lengths: number[]
T est le type de l'élément d'entrée, U est ce que renvoie le callback : ils n'ont pas à coïncider, et TypeScript infère les deux indépendamment à partir de la façon dont vous l'appelez.
Paramètres de type par défaut
Comme les paramètres de fonction par défaut, les génériques peuvent avoir des valeurs par défaut, utiles quand un paramètre de type est généralement le même mais doit rester modifiable :
interface PaginatedResponse<T = unknown> {
items: T[];
page: number;
totalPages: number;
}
function emptyPage(): PaginatedResponse {
return { items: [], page: 1, totalPages: 1 }; // T defaults to unknown
}
function usersPage(): PaginatedResponse<User> {
return { items: [], page: 1, totalPages: 1 }; // T explicitly User
}
Une erreur courante : recourir trop tôt aux génériques
Toute fonction avec un paramètre n'a pas besoin d'être générique. Si une fonction n'opère jamais que sur string, écrire function process<T extends string>(input: T) ajoute un paramètre de type qui ne varie jamais et ne justifie jamais sa complexité. Les génériques valent leur coût quand la relation entre le type d'entrée et le type de sortie doit réellement être préservée au cours d'un appel : un first<T> qui renvoie le même type d'élément que celui qu'il a reçu, un Box<T> qui enveloppe des valeurs quelconques, un mapArray<T, U> qui transforme un type en un autre. Si le type ne change jamais d'un usage à l'autre, un type concret est plus clair qu'un générique que personne n'instancie différemment.
À retenir
Les génériques existent pour préserver une relation entre types que any effacerait et que l'écriture de N surcharges quasi identiques dupliquerait. Le modèle mental qui fait le déclic : un paramètre de type est un paramètre, comme un argument de fonction, sauf que ce que vous passez est un type, et TypeScript le suit dans le reste de la signature comme il suit une valeur.