Génériques#
Motivation et principe#
L’un des problèmes les plus récurrents en développement logiciel est la duplication de code causée par la nécessité de traiter des données de types différents. Sans aucun mécanisme de généricité, on serait contraint d’écrire une fonction par type manipulé, ou d’abandonner toute sûreté en utilisant le type any.
Considérons une fonction d’identité, la plus simple qui soit : elle reçoit une valeur et la renvoie telle quelle. Sans génériques, on est face à un dilemme :
// Option 1 : une fonction par type → duplication
function identiteNombre(arg: number): number { return arg; }
function identiteChaine(arg: string): string { return arg; }
function identiteBoolean(arg: boolean): boolean { return arg; }
// Option 2 : utiliser any → perte totale de la sûreté de type
function identiteAny(arg: any): any { return arg; }
const résultat = identiteAny(42);
// TypeScript ne sait plus que résultat est un number
Les génériques résolvent ce dilemme de façon élégante : on paramètre la fonction par un type variable T, qui est résolu au moment de l’appel.
Définition 13 (Générique)
Un générique est un mécanisme permettant de définir des fonctions, des classes ou des types qui fonctionnent avec un éventail de types différents tout en conservant les informations de type à chaque usage. Le paramètre de type, conventionnellement noté T, U, K, V, etc., agit comme une variable dont la valeur est un type plutôt qu’une donnée. Il est résolu par inférence ou par annotation explicite lors de l’appel.
La fonction d’identité générique s’écrit :
function identite<T>(arg: T): T {
return arg;
}
// Annotation explicite du type
const n = identite<number>(42); // T est résolu en number
const s = identite<string>("ok"); // T est résolu en string
// Inférence automatique (le compilateur déduit T depuis l'argument)
const b = identite(true); // T inféré en boolean
Le paramètre de type T circule : il capture le type de l’argument et garantit que le type de retour est exactement le même. Là où any effaçait toute information, T la préserve.
Fonctions génériques#
Les fonctions génériques peuvent déclarer plusieurs paramètres de type, chacun représentant un rôle indépendant.
// Échange deux valeurs — T et U peuvent être de types différents
function echanger<T, U>(a: T, b: U): [U, T] {
return [b, a];
}
const [x, y] = echanger("hello", 42);
// x : number, y : string — la précision est totale
// Obtenir la première valeur d'un tableau
function premier<T>(tableau: T[]): T | undefined {
return tableau[0];
}
const premierNom = premier(["Alice", "Bob", "Carol"]);
// Type inféré : string | undefined
Remarque 11
L”inférence de type pour les génériques est l’une des fonctionnalités les plus puissantes de TypeScript. Dans la grande majorité des cas, il n’est pas nécessaire d’annoter explicitement le paramètre de type lors de l’appel : le compilateur le déduit depuis les arguments fournis. L’annotation explicite (identite<number>(42)) reste utile lorsque l’inférence est ambiguë ou que l’on souhaite forcer un type plus large que celui inféré.
Les fonctions génériques peuvent également prendre des fonctions de rappel (callbacks) dont le type dépend des paramètres de type :
function transformer<T, U>(tableau: T[], fn: (element: T) => U): U[] {
return tableau.map(fn);
}
const longueurs = transformer(["alpha", "beta", "gamma"], s => s.length);
// longueurs : number[]
const doubles = transformer([1, 2, 3], n => n * 2);
// doubles : number[]
Exemple 5 (Fusion de tableaux typés)
function fusionner<T>(a: T[], b: T[]): T[] {
return [...a, ...b];
}
const nombres = fusionner([1, 2, 3], [4, 5, 6]);
// nombres : number[]
const noms = fusionner(["Alice", "Bob"], ["Carol"]);
// noms : string[]
// TypeScript signale une erreur si les types ne correspondent pas :
// fusionner([1, 2], ["trois"]); // Erreur : string n'est pas assignable à number
Interfaces et types génériques#
Les interfaces peuvent également être paramétrées par des types. La bibliothèque standard de TypeScript en est remplie : Array<T>, Promise<T>, Map<K, V>, Set<T>, ReadonlyArray<T>, etc. sont toutes des interfaces ou classes génériques.
// La bibliothèque standard définit Array<T> approximativement ainsi :
interface Array<T> {
length: number;
push(...items: T[]): number;
pop(): T | undefined;
map<U>(callbackfn: (value: T, index: number, array: T[]) => U): U[];
filter(predicate: (value: T) => boolean): T[];
// ...
}
On peut créer ses propres interfaces et types génériques pour modéliser des structures de données réutilisables :
interface Paire<T, U> {
premier: T;
second: U;
}
type ResultatOperation<T, E = Error> =
| { succes: true; valeur: T }
| { succes: false; erreur: E };
// Utilisation
const coordonnees: Paire<number, number> = { premier: 48.8566, second: 2.3522 };
function diviser(a: number, b: number): ResultatOperation<number> {
if (b === 0) return { succes: false, erreur: new Error("Division par zéro") };
return { succes: true, valeur: a / b };
}
const résultat = diviser(10, 2);
if (résultat.succes) {
console.log(résultat.valeur); // 5
}
Définition 14 (Interface générique)
Une interface générique déclare un ou plusieurs paramètres de type entre chevrons, utilisables dans toutes les signatures de propriétés et méthodes qu’elle contient. Lors de l’utilisation de l’interface, on peut soit fournir le type explicitement (Paire<string, number>), soit laisser TypeScript l’inférer depuis le contexte.
Les alias de type génériques (type) offrent les mêmes possibilités, avec en plus la capacité d’exprimer des unions, des intersections et des types conditionnels, ce qui les rend plus puissants pour la métaprogrammation de types.
Contraintes sur les paramètres de type#
Sans contrainte, un paramètre de type T est totalement inconnu : on ne peut pas accéder à ses propriétés ni appeler ses méthodes. Les contraintes permettent d’indiquer au compilateur que T doit au moins satisfaire une certaine structure.
Définition 15 (Contrainte générique)
Une contrainte s’exprime avec le mot-clé extends dans la déclaration du paramètre de type : <T extends Contrainte>. Elle indique que T doit être assignable à Contrainte. Cela ne signifie pas que T est exactement Contrainte : T peut être un type plus précis, comme une sous-interface ou un type littéral.
// Sans contrainte : accès à .length interdit, T est inconnu
function longueur<T>(arg: T): number {
return arg.length; // Erreur : Property 'length' does not exist on type 'T'
}
// Avec contrainte : on s'assure que T possède length
interface AvecLongueur {
length: number;
}
function longueur<T extends AvecLongueur>(arg: T): number {
return arg.length; // ✓ garanti par la contrainte
}
longueur("bonjour"); // ✓ string a length
longueur([1, 2, 3]); // ✓ Array a length
longueur({ length: 5, valeur: "test" }); // ✓ satisfait la contrainte
// longueur(42); // ✗ number n'a pas de propriété length
La combinaison de keyof et des accès indexés T[K] est particulièrement puissante pour manipuler des propriétés de façon générique et sûre :
// K doit être une clé de T — accès indexé garanti sûr
function obtenirPropriete<T, K extends keyof T>(objet: T, cle: K): T[K] {
return objet[cle];
}
interface Utilisateur {
id: number;
nom: string;
actif: boolean;
}
const utilisateur: Utilisateur = { id: 1, nom: "Alice", actif: true };
const nom = obtenirPropriete(utilisateur, "nom");
// nom : string — type précis inféré
const id = obtenirPropriete(utilisateur, "id");
// id : number
// obtenirPropriete(utilisateur, "email"); // Erreur : "email" n'est pas une clé de Utilisateur
Remarque 12
keyof T produit une union de toutes les clés de T sous forme de types littéraux de chaînes (et/ou de nombres). Pour Utilisateur, keyof Utilisateur équivaut à "id" | "nom" | "actif". La contrainte K extends keyof T garantit que K est bien l’une de ces clés, et T[K] représente le type de la valeur associée à cette clé : c’est ce qu’on appelle un type d’accès indexé.
Paramètres de type par défaut#
Depuis TypeScript 2.3, il est possible de donner une valeur par défaut à un paramètre de type. Cela simplifie l’usage courant sans forcer l’utilisateur à répéter le type lorsqu’il est prévisible.
// E vaut Error par défaut — la plupart des appelants n'ont pas besoin de le préciser
type Résultat<T, E = Error> =
| { ok: true; valeur: T }
| { ok: false; erreur: E };
function charger(url: string): Promise<Résultat<string>> {
// E est Error par défaut
}
// Quand on veut un type d'erreur personnalisé, on le précise explicitement
type ErreurRéseau = { code: number; message: string };
type RésultatRéseau = Résultat<Blob, ErreurRéseau>;
// Interface de composant React-like avec prop par défaut
interface Composant<Props = Record<string, unknown>> {
rendu(props: Props): string;
clé?: string;
}
Variance#
La variance décrit comment les relations de sous-typage entre types simples se propagent aux types génériques. C’est un concept fondamental pour comprendre pourquoi certaines assignations sont acceptées ou refusées.
Définition 16 (Covariance et contravariance)
Un type générique
G<T>est covariant enTsi, dès queAest un sous-type deB, alorsG<A>est un sous-type deG<B>. Les producteurs de valeurs (fonctions qui retournentT) sont covariants.Un type générique
G<T>est contravariant enTsi, dès queAest un sous-type deB, alorsG<B>est un sous-type deG<A>(la relation s’inverse). Les consommateurs de valeurs (fonctions qui acceptentTen paramètre) sont contravariants.Un type est invariant s’il n’est ni covariant ni contravariant.
Voici une illustration intuitive avec des animaux :
class Animal { respirer() { return "..."; } }
class Chien extends Animal { aboyer() { return "Ouaf !"; } }
// Tableau : covariant en T
// Chien[] est assignable à Animal[] car un tableau de chiens est bien un tableau d'animaux
const chiens: Chien[] = [new Chien()];
const animaux: Animal[] = chiens; // ✓ covariance
// Fonction en position de paramètre : contravariante
type Gestionnaire<T> = (arg: T) => void;
const gererAnimal: Gestionnaire<Animal> = (a) => a.respirer();
const gererChien: Gestionnaire<Chien> = (c) => c.aboyer();
// Un Gestionnaire<Animal> peut être utilisé là où on attend un Gestionnaire<Chien>
// car gérer n'importe quel Animal est suffisamment général pour gérer un Chien
const fn: Gestionnaire<Chien> = gererAnimal; // ✓ contravariance
// const fn2: Gestionnaire<Animal> = gererChien; // ✗ trop spécifique
Remarque 13
TypeScript adopte par défaut une variance structurelle et permissive : les tableaux et certains objets mutables sont traités comme covariants même s’ils devraient être invariants en toute rigueur. Cela simplifie l’usage courant mais peut mener à des erreurs d’exécution subtiles avec des tableaux mutables. La vérification stricte de la variance des paramètres de fonctions est activée par l’option --strictFunctionTypes (incluse dans --strict).
infer — introduction#
Le mot-clé infer est une extension réservée aux types conditionnels (traités en détail au chapitre 8), mais il est utile d’en poser les bases dès maintenant.
Définition 17 (Le mot-clé infer)
infer est un mot-clé utilisable uniquement dans la branche conditionelle d’un type conditionnel (T extends ... ? X : Y). Il introduit une variable de type locale qui est inférée par le compilateur à partir de la structure de T. Il permet d’extraire des sous-types d’un type composé sans avoir à les connaître à l’avance.
Un exemple simple : extraire le type des éléments d’un tableau.
// Sans infer — on ne peut pas extraire le type d'élément facilement
type ElementDeTableau<T> = T extends Array<infer Element> ? Element : never;
type E1 = ElementDeTableau<string[]>; // string
type E2 = ElementDeTableau<number[]>; // number
type E3 = ElementDeTableau<boolean[]>; // boolean
type E4 = ElementDeTableau<string>; // never — string n'est pas un tableau
infer sera indispensable pour implémenter des types utilitaires comme ReturnType<T> ou Parameters<T>. Le mécanisme complet, avec distribution sur les unions et récursivité, est présenté au chapitre 8.
Visualisation : spécialisation d’un type générique#
Résumé#
Ce chapitre a présenté le mécanisme des génériques, pierre angulaire du système de types avancé de TypeScript :
Sans génériques, la réutilisabilité implique soit la duplication de code (une fonction par type), soit l’abandon de la sûreté (
any). Les génériques résolvent ce dilemme en capturant le type dans une variable résolue à l’appel.Les fonctions génériques peuvent déclarer plusieurs paramètres de type (
<T, U>), bénéficient de l’inférence automatique depuis les arguments, et préservent les informations de type à travers les transformations.Les interfaces et types génériques permettent de modéliser des structures paramétrées (
Paire<T, U>,Résultat<T, E>), avec possibilité de valeurs par défaut (E = Error).Les contraintes (
T extends X) rendent le paramètre de type plus spécifique, permettant d’accéder à ses propriétés.keyof TetT[K]permettent des accès indexés typés et sûrs.La variance (covariance, contravariance) décrit la propagation des relations de sous-typage ;
--strictFunctionTypesen renforce la vérification pour les paramètres de fonctions.inferintroduit une variable de type locale dans les types conditionnels, permettant l’extraction de sous-types — mécanisme fondamental pour implémenter les types utilitaires de la bibliothèque standard.
Dans le chapitre suivant, nous étudierons les types utilitaires fournis par TypeScript — Partial, Required, Pick, Omit, ReturnType, etc. — qui mettent en œuvre les génériques et les contraintes pour transformer des types existants de façon déclarative.