5.2 · Generics
Les diapositives de la séance 5
- expliquer le problème que les generics résolvent : duplication ou perte de typage
- lire et écrire une signature générique
<T>sur une fonction - écrire une interface et une classe génériques
- contraindre un paramètre de type avec
extends - reconnaître les generics dans le code existant :
Array<T>,Map<K, V>,Promise<T>
Les tableaux du chapitre 2.3 · Tableaux et les interfaces et les classes des chapitres 2.2 · Objets de données et 3.1 · Classes et encapsulation.
Code du chapitre : télécharger app.ts
Le problème
Vous voulez une fonction qui renvoie le premier élément d’un tableau. Première tentative :
function premierNombre(tab: number[]): number | undefined {
return tab[0];
}Parfait pour les nombres. Il vous faut maintenant la même chose pour des chaînes, puis pour des User, puis pour des Point… Deux mauvaises solutions se présentent.
Dupliquer : quatre fonctions identiques à la ligne de type près :
function premierNombre(tab: number[]): number | undefined { return tab[0]; }
function premiereChaine(tab: string[]): string | undefined { return tab[0]; }
function premierUser(tab: User[]): User | undefined { return tab[0]; }Utiliser any : une seule fonction, mais qui détruit le typage :
function premier(tab: any[]): any {
return tab[0];
}
const n = premier([1, 2, 3]);
n.toUpperCase(); // compile sans broncher, plante à l'exécutionLe compilateur ne sait plus rien : n est any, donc tout est permis, donc rien n’est vérifié. Vous avez perdu l’intégralité du bénéfice de TypeScript.
Or il existe un lien évident entre l’entrée et la sortie : si on donne un tableau de number, on récupère forcément un number. Les generics servent exactement à écrire ce lien.
L’idée : un paramètre dont la valeur est un type
Une fonction ordinaire prend des valeurs en paramètre. Une fonction générique prend en plus des types en paramètre.
function premier<T>(tab: T[]): T | undefined {
return tab[0];
}Lisez la signature à voix haute : « pour n’importe quel type T, premier prend un tableau de T et renvoie un T ou undefined ». Le <T> après le nom déclare le paramètre de type, exactement comme les parenthèses déclarent les paramètres de valeur.
const a = premier([1, 2, 3]); // T = number → a: number | undefined
const b = premier(["x", "y"]); // T = string → b: string | undefined
const c = premier<boolean>([true, false]); // T forcé explicitement
a?.toFixed(2); // OK
b?.toUpperCase(); // OK
a?.toUpperCase(); // ERREUR : Property 'toUpperCase' does not exist on type 'number'Dans la plupart des cas, TypeScript déduit T à partir de l’argument : vous n’avez rien à écrire entre chevrons. Ne le précisez que lorsque l’inférence échoue ou produit un type trop large.
T pour Type, K pour Key, V pour Value, E pour Element, R pour Return. Rien ne vous oblige à les suivre : <TElement> ou <Contenu> sont valides et parfois plus lisibles. Une seule lettre suffit tant que le generic reste court.
Vous venez du C ?
En C, pour écrire un conteneur générique, vous avez deux options : les macros du préprocesseur, ou void* avec des memcpy et beaucoup de foi. C’est laborieux dans le premier cas, et non vérifié dans le second.
Les generics de TypeScript ressemblent davantage aux templates du C++ : le compilateur vérifie le code pour chaque type employé. Différence notable : en C++ le compilateur génère une version machine par type instancié ; en TypeScript, les generics disparaissent totalement à la transpilation. Il ne reste aucune trace de <T> dans le JavaScript produit : c’est un outil purement statique, à coût d’exécution nul.
Generics sur une fonction
Plusieurs paramètres de type sont possibles :
function paire<A, B>(premier: A, second: B): [A, B] {
return [premier, second];
}
const p = paire("Alice", 25); // p: [string, number]Generics sur une interface
interface Resultat<T> {
succes: boolean;
donnee: T;
message?: string;
}
const r1: Resultat<number> = { succes: true, donnee: 42 };
const r2: Resultat<string[]> = { succes: true, donnee: ["a", "b"] };Une interface générique n’est pas un type à elle seule : Resultat tout court est une erreur. Il faut toujours fournir l’argument de type (Resultat<number>) de la même façon qu’on ne peut pas appeler une fonction sans lui passer ses arguments.
Generics sur une classe
Une classe réunit des champs private et les méthodes qui y accèdent par this ; c’est ce qui garantit ici qu’on ne range dans le conteneur que des valeurs du bon type. Voir 3.1 · Garantir un invariant.
C’est l’exemple du chapitre : un conteneur qui stocke n’importe quel type, sans jamais mélanger.
app.ts
class GenericStorage<T> {
private data: Map<string, T> = new Map();
set(key: string, value: T): void {
this.data.set(key, value);
}
// Map.get renvoie undefined si la clé est absente : le type le dit explicitement
get(key: string): T | undefined {
return this.data.get(key);
}
size(): number {
return this.data.size;
}
}À l’usage, T est fixé une fois pour toutes au moment du new :
app.ts
const numberStorage = new GenericStorage<number>();
numberStorage.set("one", 1);
numberStorage.set("two", 2);
console.log(numberStorage.get("one")); // 1
// numberStorage.set("three", "3"); // ERREUR : string n'est pas number
const stringStorage = new GenericStorage<string>();
stringStorage.set("hello", "world");
console.log(stringStorage.get("hello")); // "world"Deux instances, deux types, une seule classe écrite. Sans generics, il aurait fallu écrire NumberStorage, StringStorage, UserStorage… ou revenir à any et perdre toute vérification.
Notez le type de retour T | undefined. C’est une bonne pratique importante : Map.get renvoie undefined quand la clé n’existe pas, et le type le dit. Le compilateur vous oblige alors à traiter ce cas :
const valeur = numberStorage.get("trois");
console.log(valeur.toFixed(2)); // ERREUR : 'valeur' is possibly 'undefined'
console.log(valeur?.toFixed(2)); // OK : affiche undefined si absentT n’existe qu’à la compilation. Ces deux écritures sont impossibles dans une méthode générique :
if (valeur instanceof T) { } // ERREUR : 'T' only refers to a type
const x = new T(); // ERREUR : 'T' only refers to a typeAprès transpilation, GenericStorage<number> et GenericStorage<string> sont la même classe JavaScript. Aucune information de type ne survit à l’exécution.
Contraindre un paramètre de type
<T> seul signifie « absolument n’importe quel type ». Conséquence : à l’intérieur de la fonction, vous ne pouvez rien faire de la valeur, puisque TypeScript ignore tout d’elle.
function afficherLongueur<T>(x: T): void {
console.log(x.length); // ERREUR : Property 'length' does not exist on type 'T'
}La contrainte extends restreint les types acceptables et, en échange, donne accès aux membres garantis :
interface AvecLongueur {
length: number;
}
function afficherLongueur<T extends AvecLongueur>(x: T): void {
console.log(x.length); // OK : tout T possède désormais length
}
afficherLongueur("bonjour"); // 7 : string a une length
afficherLongueur([1, 2, 3]); // 3 : un tableau aussi
afficherLongueur(42); // ERREUR : number n'a pas de lengthLe compromis est toujours le même : plus la contrainte est forte, moins le generic est réutilisable, mais plus vous pouvez en faire à l’intérieur.
Exemple typé plus réaliste :
interface Identifiable {
id: string;
}
function trouverParId<T extends Identifiable>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
const users = [{ id: "1", nom: "Alice" }, { id: "2", nom: "Bob" }];
const u = trouverParId(users, "2");
console.log(u?.nom); // Bob : le type complet est préservé, pas seulement `Identifiable`C’est là toute la valeur du generic : la fonction n’exige que id, mais elle rend le type complet de l’élément. Avec un paramètre typé Identifiable[], vous auriez récupéré un Identifiable et perdu nom.
extends ne signifie pas ici « hérite de » comme entre deux classes. Dans un generic, il se lit « est compatible avec », au sens structurel du chapitre 2.2 : un objet littéral qui possède un champ id: string satisfait T extends Identifiable, sans aucune classe ni aucun implements.
Les generics que vous utilisez déjà
Vous en manipulez depuis le chapitre 1.1 sans les nommer :
| Écriture | Signification |
|---|---|
Array<string> |
strictement identique à string[] |
Map<string, number> |
clés string, valeurs number |
Set<number> |
ensemble de nombres |
Promise<User> |
promesse qui produira un User (chapitre 6.1) |
Record<string, number> |
objet dont toutes les valeurs sont des nombres |
C’est aussi pour cela que [1, 2, 3].map((n) => n * 2) renvoie un number[] : la signature de map est générique, map<U>(fn: (x: T) => U): U[].
Exercices
Exercice 5.4 : Du any au generic
essentiel
Partez de ce code, qui compile mais ne protège rien :
function dernier(tab: any[]): any {
return tab[tab.length - 1];
}
const d = dernier([1, 2, 3]);
console.log(d.toUpperCase()); // aucune erreur signalée- exécutez-le et constatez le plantage ;
- réécrivez
dernieren générique, sans changer le corps de la fonction ; - vérifiez que
d.toUpperCase()est désormais refusé à la compilation ; - faites en sorte que le type de retour signale correctement le cas du tableau vide.
Critère de réussite : dernier([]) a le type undefined dans le type de retour, et aucun any ne subsiste dans le fichier.
function dernier<T>(tab: T[]): T | undefined {
return tab[tab.length - 1]; // le corps n'a pas bougé d'un caractère
}
const d = dernier([1, 2, 3]); // d: number | undefined
// d.toUpperCase();
// ERREUR : 'd' is possibly 'undefined'.
// ERREUR : Property 'toUpperCase' does not exist on type 'number'.
console.log(d?.toFixed(2)); // 3.00
const s = dernier(["a", "b"]); // s: string | undefined
console.log(s?.toUpperCase()); // B
console.log(dernier([])); // undefinedLe point clé. Seule la signature change ; le corps est identique à la version any. C’est toute la démonstration de l’exercice : les generics ne coûtent rien à l’exécution, ils ne font qu’exprimer un lien que vous connaissiez déjà : ce qui sort est du même type que ce qui entre. Après transpilation, les deux versions produisent exactement le même JavaScript.
Deux erreurs sont mises au jour d’un coup à l’étape 3, et il faut les distinguer :
Property 'toUpperCase' does not exist on type 'number'est l’erreur attendue, celle queanymasquait ;'d' is possibly 'undefined'vient du| undefinedde l’étape 4. C’est une seconde protection, indépendante de la première.
Précision importante sur l’étape 4 : ce | undefined est à votre charge. Par défaut, TypeScript type tab[i] comme T même hors bornes, donc function dernier<T>(tab: T[]): T compile sans broncher tout en mentant sur un tableau vide. L’option noUncheckedIndexedAccess du tsconfig.json ajoute automatiquement le | undefined à tout accès indexé ; activez-la si vous voulez que le compilateur vous couvre.
Dernier détail amusant : dans dernier([]), TypeScript n’a aucun élément pour inférer T et choisit never. Le type du résultat est donc never | undefined, c’est-à-dire undefined, ce qui est exactement la vérité.
Exercice 5.5 : Une file générique
entraînement transfert
Implémentez une classe Queue<T>, une file premier entré, premier sorti :
class Queue<T> {
// TODO : un champ privé pour stocker les éléments
enqueue(element: T): void {
// TODO : ajoute en fin de file
}
dequeue(): T | undefined {
// TODO : retire et renvoie l'élément de tête, undefined si la file est vide
}
size(): number {
// TODO
}
}
const numberQueue = new Queue<number>();
// TODO : tester enqueue, dequeue, size
const stringQueue = new Queue<string>();
// TODO : tester enqueue, dequeue, sizeCritères de réussite :
numberQueue.enqueue("trois")est refusé à la compilation ;dequeue()sur une file vide renvoieundefinedsans lever d’erreur ;- le champ de stockage est
private; - aucun
anydans le fichier.
Un tableau suffit : push pour enfiler, shift pour défiler. shift renvoie déjà T | undefined sur un tableau vide.
class Queue<T> {
private items: T[] = [];
enqueue(element: T): void {
this.items.push(element); // on ajoute EN FIN
}
dequeue(): T | undefined {
return this.items.shift(); // on retire EN TÊTE
}
size(): number {
return this.items.length;
}
}
const numberQueue = new Queue<number>();
numberQueue.enqueue(10);
numberQueue.enqueue(20);
numberQueue.enqueue(30);
console.log(numberQueue.size()); // 3
console.log(numberQueue.dequeue()); // 10 : premier entré, premier sorti
console.log(numberQueue.dequeue()); // 20
console.log(numberQueue.size()); // 1
console.log(numberQueue.dequeue()); // 30
console.log(numberQueue.dequeue()); // undefined : file vide, aucune exception
// numberQueue.enqueue("trois");
// ERREUR : Argument of type 'string' is not assignable to parameter of type 'number'.
const stringQueue = new Queue<string>();
stringQueue.enqueue("Alice");
stringQueue.enqueue("Bob");
console.log(stringQueue.size()); // 2
console.log(stringQueue.dequeue()); // Alice
console.log(stringQueue.dequeue()); // Bob
console.log(stringQueue.dequeue()); // undefined
console.log(stringQueue.size()); // 0Le point clé. T est fixé au moment du new, pas à l’appel des méthodes. À partir de new Queue<number>(), toutes les signatures de cette instance sont réécrites avec number : enqueue(element: number), dequeue(): number | undefined. C’est ce qui rend numberQueue.enqueue("trois") impossible alors que la classe, elle, accepte n’importe quoi. Une seule classe écrite, autant de files typées que voulu.
Le T | undefined de dequeue n’est pas une précaution ajoutée à la main : c’est exactement ce que renvoie Array.prototype.shift. TypeScript vous le transmet, et vous contraint à traiter la file vide. Écrire dequeue(): T provoquerait Type 'T | undefined' is not assignable to type 'T'.
L’erreur classique de l’exercice est de confondre file et pile. Une file utilise push + shift ; avec push + pop vous obtenez une pile (LIFO), et numberQueue.dequeue() renverrait 30 au lieu de 10. Rien dans le typage ne vous préviendra : c’est un bug de logique, pas de type.
Variante courante : stocker les éléments dans un Map indexée ou garder un indice de tête, pour éviter le coût de shift, qui réindexe tout le tableau. Inutile ici, mais c’est ce que fait une vraie implémentation de file.
Exercice 5.6 : Contraintes
approfondissement
- écrivez
interface Nomme { nom: string }; - écrivez
trierParNom<T extends Nomme>(items: T[]): T[]qui renvoie une copie triée par ordre alphabétique, sans altérer le tableau d’origine ; - testez-la sur un tableau d’objets ayant
nomet d’autres propriétés, puis vérifiez que ces autres propriétés restent accessibles sur le résultat ; - vérifiez qu’un tableau d’objets sans
nomest refusé ; - question : que se passerait-il si la signature était
trierParNom(items: Nomme[]): Nomme[]? Testez et expliquez.
Critère de réussite : à l’étape 3, l’éditeur autocomplète bien les propriétés supplémentaires sur les éléments du tableau retourné.
interface Nomme {
nom: string;
}
function trierParNom<T extends Nomme>(items: T[]): T[] {
return [...items].sort((a, b) => a.nom.localeCompare(b.nom));
}
const employes = [
{ nom: "Zoe", salaire: 42000 },
{ nom: "Alice", salaire: 51000 },
{ nom: "Émile", salaire: 38000 },
];
const tries = trierParNom(employes); // tries: { nom: string; salaire: number }[]
tries.forEach((e) => console.log(e.nom, e.salaire)); // salaire reste accessible
// Alice 51000
// Émile 38000
// Zoe 42000
console.log(employes[0].nom); // Zoe : le tableau d'origine est intact
// Étape 4 :
// trierParNom([{ age: 30 }]);
// ERREUR : Property 'nom' is missing in type '{ age: number; }'
// but required in type 'Nomme'.Étape 5, la version non générique :
function trierParNomNonGenerique(items: Nomme[]): Nomme[] {
return [...items].sort((a, b) => a.nom.localeCompare(b.nom));
}
const triesPerdus = trierParNomNonGenerique(employes); // triesPerdus: Nomme[]
// triesPerdus[0].salaire;
// ERREUR : Property 'salaire' does not exist on type 'Nomme'.L’appel est accepté : le typage structurel du chapitre 2.2 fait qu’un { nom: string; salaire: number } est un Nomme valide. Mais le type de retour annoncé est Nomme[] : TypeScript a oublié tout ce qui ne figure pas dans Nomme. À l’exécution les objets ont toujours leur salaire ; c’est l’information de type qui a été détruite au passage.
Le point clé. C’est exactement ce que le generic apporte, et rien d’autre : il conserve le lien entre le type d’entrée et le type de sortie. T extends Nomme se lit « un type, quel qu’il soit, à condition qu’il ait au moins un nom » : la fonction n’exige que nom, et vous rend le type complet. Sans generic, vous choisissez entre exiger peu et rendre peu.
Deux détails d’implémentation qui font échouer l’exercice :
sorttrie sur place et renvoie le tableau d’origine.return items.sort(...)modifieraitemployes, ce que l’énoncé interdit. D’où la copie[...items];items.slice()fait la même chose. Depuis ES2023,items.toSorted(...)évite la copie manuelle ;localeComparerespecte les accents. Avec une comparaison brutea.nom > b.nom ? 1 : -1, « Émile » passerait après « Zoe », parce que la comparaison porte alors sur les codes Unicode et queÉvaut 201, au-delà de tout l’alphabet latin non accentué.
QCM
1. Quel est le type de a ?
function premier<T>(tab: T[]): T | undefined {
return tab[0];
}
const a = premier(["x", "y"]);A. any B. string | undefined C. T D. string[]
B. TypeScript déduit T = string de l’argument. Le undefined vient du type de retour : le tableau pourrait être vide.
2. Pourquoi cette fonction est-elle refusée ?
function longueur<T>(x: T): number {
return x.length;
}A. Une fonction générique ne peut pas renvoyer number. B. Rien ne garantit que T possède length : il faut une contrainte T extends { length: number }. C. Il faut préciser <T> à chaque appel. D. length est réservé aux tableaux.
B. T seul veut dire « n’importe quel type », y compris number, qui n’a pas de length. La contrainte restreint les types acceptés et débloque leurs membres.
3. Dans une fonction générique <T> qui reçoit tab: T[] et x: unknown, laquelle de ces lignes compile ?
A. const neuf = new T(); B. if (x instanceof T) { } C. const copie: T[] = [...tab]; D. if (typeof x === T) { }
C. T n’existe qu’à la compilation : après transpilation, il n’en reste rien. On ne peut donc ni l’instancier, ni le tester à l’exécution.
À retenir
- Un generic est un paramètre dont la valeur est un type, fourni à l’appel ou au
new <T>après le nom déclare le paramètre ;Type<Argument>le fournit- Les generics remplacent la duplication et
any: réutilisable sans perdre le typage - TypeScript infère
Tla plupart du temps : n’écrivez les chevrons que si nécessaire T extends Contrainterestreint les types acceptés et débloque leurs membresTn’existe qu’à la compilation : pas denew T(), pas deinstanceof T