Maîtriser TypeScript : Développez des Applications Web Robustes et Scalables
Maîtriser TypeScript : Développez des Applications Web Robustes et Scalables

Génériques en TypeScript

Bienvenue dans cette leçon dédiée aux Génériques en TypeScript, un concept puissant et indispensable pour quiconque souhaite maîtriser le développement d'applications web robustes et scalables. Dans le cadre de notre cours "Maîtriser TypeScript", les génériques se positionnent comme une pierre angulaire pour la création de composants réutilisables et typiquement sûrs.

Introduction aux Génériques

Imaginez que vous écriviez une fonction qui doit opérer sur des données de différents types, mais avec la même logique sous-jacente. Par exemple, une fonction qui prend un élément et le retourne tel quel (une fonction d'identité).

Sans les génériques, vous seriez confronté à un dilemme :

  1. Utiliser le type any :

    function identityAny(arg: any): any {
        return arg;
    }
    let s = identityAny("hello"); // s est de type any, perte de la sécurité de type
    s.toFixed(); // Aucune erreur à la compilation, mais échouera à l'exécution si s est une chaîne !
    

    L'utilisation de any annule l'un des principaux avantages de TypeScript : la sécurité de type au moment de la compilation.

  2. Surcharger la fonction pour chaque type :

    function identityString(arg: string): string {
        return arg;
    }
    function identityNumber(arg: number): number {
        return arg;
    }
    // ... et ainsi de suite pour chaque type
    

    Cette approche conduit à une duplication de code excessive et n'est pas évolutive pour un grand nombre de types.

Les génériques résolvent ce problème en vous permettant de créer des composants (fonctions, classes, interfaces, alias de type) qui peuvent fonctionner avec n'importe quel type de données, tout en conservant la sécurité de type et en évitant la duplication de code. Ils agissent comme des "paramètres pour des types", vous permettant de spécifier des types au moment de l'utilisation, plutôt qu'au moment de la définition du composant.

1. Les Bases des Génériques : Le Type Placeholder <T>

Le concept fondamental des génériques est l'utilisation de variables de type, souvent représentées par une seule lettre comme T (pour Type), K (pour Key), V (pour Value), ou E (pour Element). Ces variables agissent comme des placeholders (espaces réservés) pour les types que vous spécifierez ultérieurement.

La syntaxe principale pour déclarer un composant générique est d'utiliser les chevrons < > après le nom de la fonction, de la classe ou de l'interface.

1.1. La Fonction d'identité Générique

Reprenons notre fonction d'identité et rendons-la générique :

/**
 * Fonction générique qui retourne l'argument tel quel.
 * Le type de l'argument et du retour est paramétré par 'T'.
 * @param arg L'argument de n'importe quel type T.
 * @returns L'argument, avec son type T conservé.
 */
function identity<T>(arg: T): T {
    return arg;
}

// --- Utilisation de la fonction générique ---

// 1. Spécification explicite du type T :
// Ici, nous indiquons explicitement que T doit être 'string'.
let outputString = identity<string>("Bonjour le monde");
console.log(outputString); // Affiche "Bonjour le monde"
console.log(typeof outputString); // Affiche "string"
// outputString.toFixed(); // Erreur de compilation : Property 'toFixed' does not exist on type 'string'. (Sécurité de type préservée !)

// 2. Inférence du type T par TypeScript :
// TypeScript est suffisamment intelligent pour inférer que T doit être 'number'
// basé sur la valeur que nous passons à la fonction.
let outputNumber = identity(123);
console.log(outputNumber); // Affiche 123
console.log(typeof outputNumber); // Affiche "number"
// outputNumber.toUpperCase(); // Erreur de compilation : Property 'toUpperCase' does not exist on type 'number'. (Sécurité de type préservée !)

// 3. Utilisation avec un type complexe (objet) :
interface User {
    id: number;
    name: string;
}
let user: User = { id: 1, name: "Alice" };
let returnedUser = identity(user);
console.log(returnedUser.name); // Affiche "Alice" (returnedUser est de type User)

Explication du code :

  • function identity<T>(arg: T): T :
    • <T> : Déclare une variable de type T pour cette fonction. T est un placeholder pour le type réel qui sera utilisé.
    • arg: T : Indique que le paramètre arg sera du type T.
    • : T : Indique que le type de retour de la fonction sera également T.
  • Grâce à T, la fonction identity est réutilisable pour n'importe quel type de données (string, number, User, etc.), et la sécurité de type est entièrement maintenue, car TypeScript connaît le type spécifique de l'argument et du retour au moment de l'utilisation.

2. Génériques avec des Fonctions : Flexibilité et Sécurité Approfondie

Les génériques offrent encore plus de flexibilité pour les fonctions, notamment avec la possibilité de définir plusieurs paramètres de type ou d'appliquer des contraintes.

2.1. Plusieurs Paramètres de Type

Vous pouvez utiliser plusieurs variables de type si votre fonction doit manipuler des données de types différents mais liés :

/**
 * Crée un objet simple avec une clé et une valeur.
 * Les types de la clé et de la valeur sont paramétrés.
 * @param key La clé de l'objet, de type TKey.
 * @param value La valeur associée à la clé, de type TValue.
 * @returns Un objet avec la clé et la valeur spécifiées, conservant leurs types.
 */
function createKeyValuePair<TKey, TValue>(key: TKey, value: TValue): { key: TKey; value: TValue } {
    return { key, value };
}

let productInfo = createKeyValuePair("productName", "Laptop Pro");
// productInfo est de type { key: string; value: string; }
console.log(productInfo.key);
console.log(productInfo.value);

let studentScore = createKeyValuePair(101, 95.5);
// studentScore est de type { key: number; value: number; }
console.log(`Student ID: ${studentScore.key}, Score: ${studentScore.value}`);

2.2. Contraintes de Type (extends)

Parfois, vous avez besoin que votre type générique ait certaines capacités. Par exemple, si vous voulez travailler avec la propriété length d'un tableau ou d'une chaîne, vous devez vous assurer que le type passé possède bien cette propriété. C'est là que les contraintes de type entrent en jeu, en utilisant le mot-clé extends.

/**
 * Interface pour les types ayant une propriété 'length' de type number.
 */
interface Lengthwise {
    length: number;
}

/**
 * Fonction générique qui affiche la longueur de l'argument.
 * Le type T est contraint à implémenter l'interface Lengthwise.
 * @param arg L'argument dont la longueur sera affichée.
 * @returns L'argument lui-même.
 */
function loggingIdentityWithLength<T extends Lengthwise>(arg: T): T {
    console.log(`Longueur de l'argument : ${arg.length}`); // OK, car T est garanti d'avoir 'length'
    return arg;
}

// --- Utilisation valide ---
loggingIdentityWithLength("Bonjour"); // Affiche "Longueur de l'argument : 7"
loggingIdentityWithLength([10, 20, 30]); // Affiche "Longueur de l'argument : 3"

// Utilisation avec un objet conforme à Lengthwise
let myObject = { value: "test", length: 42 };
loggingIdentityWithLength(myObject); // Affiche "Longueur de l'argument : 42"

// --- Utilisation invalide (erreur de compilation) ---
// loggingIdentityWithLength(3);
// Erreur: 'number' n'est pas assignable au type 'Lengthwise'.
// Property 'length' is missing in type 'number'.
// loggingIdentityWithLength({ prop: "hello" });
// Erreur: Argument of type '{ prop: string; }' is not assignable to parameter of type 'Lengthwise'.
// Property 'length' is missing in type '{ prop: string; }'.

Explication : La contrainte T extends Lengthwise garantit que tout type utilisé pour T doit avoir une propriété length de type number. Cela permet à TypeScript de valider le code au moment de la compilation, évitant ainsi les erreurs d'exécution potentielles.

2.3. Contraintes de Clé avec keyof

Le mot-clé keyof est extrêmement utile en combinaison avec les génériques pour s'assurer qu'une variable de type représente une clé valide d'un objet.

/**
 * Récupère la valeur d'une propriété d'un objet en toute sécurité.
 * @param obj L'objet duquel récupérer la propriété.
 * @param key La clé de la propriété à récupérer. Le type de la clé (TKey)
 *            est contraint à être l'une des clés de l'objet (keyof TObject).
 * @returns La valeur de la propriété spécifiée.
 */
function getProperty<TObject, TKey extends keyof TObject>(obj: TObject, key: TKey): TObject[TKey] {
    return obj[key];
}

let person = { name: "Alice", age: 30, city: "Paris" };

let personName = getProperty(person, "name"); // personName est de type string
console.log(`Nom: ${personName}`);

let personAge = getProperty(person, "age");   // personAge est de type number
console.log(`Age: ${personAge}`);

// --- Erreur de compilation si la clé n'existe pas ---
// let personCountry = getProperty(person, "country");
// Erreur: Argument of type '"country"' is not assignable to parameter of type '"name" | "age" | "city"'.

Explication : TKey extends keyof TObject signifie que le type de TKey doit être l'une des chaînes littérales qui sont des clés valides de TObject. Cela apporte une forte sécurité de type lors de l'accès aux propriétés d'objets dynamiquement.

3. Génériques avec des Interfaces et des Types

Les interfaces et les alias de type peuvent également être génériques, ce qui est parfait pour définir des structures de données réutilisables et typées.

// Interface générique
interface Box<T> {
    value: T;
}

// Utilisation de l'interface Box avec différents types
let stringBox: Box<string> = { value: "Un texte générique" };
let numberBox: Box<number> = { value: 12345 };
let booleanBox: Box<boolean> = { value: true };

console.log(stringBox.value.toUpperCase()); // "UN TEXTE GÉNÉRIQUE"
// stringBox.value.toFixed(2); // Erreur de compilation : toFixed n'existe pas sur string

console.log(numberBox.value.toFixed(2)); // "12345.00"
// numberBox.value.toUpperCase(); // Erreur de compilation : toUpperCase n'existe pas sur number

// Type alias générique (pour des types plus complexes ou des unions)
/**
 * Représente le résultat d'une opération asynchrone qui peut soit réussir
 * avec des données de type TData, soit échouer avec une erreur de type TError.
 */
type AsyncResult<TData, TError> = { success: true; data: TData } | { success: false; error: TError };

// Exemple d'utilisation du type AsyncResult
function fetchData(): AsyncResult<string[], string> {
    // Simule une opération d'API qui peut réussir ou échouer
    if (Math.random() > 0.5) {
        return { success: true, data: ["Item 1", "Item 2", "Item 3"] };
    } else {
        return { success: false, error: "Échec du chargement des données." };
    }
}

let apiResponse = fetchData();

if (apiResponse.success) {
    console.log("Données chargées avec succès :");
    apiResponse.data.forEach(item => console.log(`- ${item}`));
} else {
    console.error("Erreur de chargement :");
    console.error(apiResponse.error);
}

Explication :

  • Box<T> crée une interface de "boîte" qui peut contenir n'importe quel type T.
  • AsyncResult<TData, TError> est un type union générique, idéal pour gérer les réponses d'API ou d'autres opérations avec des états de succès/erreur, où les types des données et des erreurs peuvent varier.

4. Génériques avec des Classes

Les classes peuvent également être génériques pour construire des structures de données qui encapsulent la logique sans se soucier du type précis des éléments qu'elles gèrent. C'est particulièrement utile pour les collections (listes, piles, files d'attente, etc.).

/**
 * Implémentation générique d'une Pile (Stack).
 * Cette pile peut contenir des éléments de n'importe quel type T.
 */
class GenericStack<T> {
    private elements: T[] = [];

    /**
     * Ajoute un élément au sommet de la pile.
     * @param element L'élément de type T à ajouter.
     */
    push(element: T): void {
        this.elements.push(element);
    }

    /**
     * Retire et retourne l'élément au sommet de la pile.
     * @returns L'élément de type T retiré, ou undefined si la pile est vide.
     */
    pop(): T | undefined {
        return this.elements.pop();
    }

    /**
     * Retourne l'élément au sommet de la pile sans le retirer.
     * @returns L'élément de type T, ou undefined si la pile est vide.
     */
    peek(): T | undefined {
        return this.elements[this.elements.length - 1];
    }

    /**
     * Vérifie si la pile est vide.
     * @returns True si la pile est vide, false sinon.
     */
    isEmpty(): boolean {
        return this.elements.length === 0;
    }

    /**
     * Retourne le nombre d'éléments dans la pile.
     */
    size(): number {
        return this.elements.length;
    }
}

// --- Utilisation de la classe GenericStack ---

// Pile pour les nombres
let numberStack = new GenericStack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(`Pile de nombres: ${numberStack.peek()}`); // 20
console.log(`Pop: ${numberStack.pop()}`);             // 20
console.log(`Pile vide ? ${numberStack.isEmpty()}`);    // false
numberStack.push(30);
console.log(`Taille de la pile: ${numberStack.size()}`); // 2

// Pile pour les chaînes de caractères
let stringStack = new GenericStack<string>();
stringStack.push("First");
stringStack.push("Second");
stringStack.push("Third");
console.log(`Pile de chaînes: ${stringStack.peek()}`); // Third
console.log(`Pop: ${stringStack.pop()}`);              // Third
console.log(`Pop: ${stringStack.pop()}`);              // Second

// stringStack.push(123); // Erreur de compilation : Argument of type 'number' is not assignable to parameter of type 'string'.

Explication : La classe GenericStack<T> permet de créer des instances de piles qui stockent des éléments d'un type spécifique T. Les méthodes push, pop, peek, etc., opèrent toujours sur des éléments de type T, garantissant que la pile ne contiendra jamais de types mixtes, et que toutes les opérations de la pile sont typiquement sûres.

5. Inférence de Type et Bonnes Pratiques

  • Inférence de Type : TypeScript est généralement très bon pour inférer les types génériques, ce qui rend votre code plus concis. Dans l'exemple identity(123), TypeScript a automatiquement inféré T comme number.

  • Spécification Explicite : Bien que l'inférence soit pratique, il y a des situations où vous pourriez vouloir spécifier le type générique explicitement. Cela peut améliorer la lisibilité du code ou résoudre des ambiguïtés lorsque TypeScript ne peut pas inférer le type souhaité (par exemple, lors de la création d'une instance générique vide).

    // Inférence implicite (T est inféré comme 'string')
    const myText = identity("Hello TypeScript");
    
    // Spécification explicite (pour la clarté ou si l'inférence est ambiguë)
    const emptyArray: Array<number> = []; // Indique clairement que c'est un tableau de nombres
    
  • Noms des Variables de Type : Utilisez des noms de variables de type descriptifs (ex: TUser, TItem, TResponseData, TErrorCode) plutôt que des lettres uniques si vous avez plusieurs variables de type ou si leur rôle n'est pas immédiatement clair. Cependant, pour des cas simples (comme T pour une fonction d'identité), une seule lettre est la convention.

Conclusion

Les génériques sont un pilier fondamental de TypeScript pour la réutilisabilité et la sécurité de type. Ils vous permettent de concevoir des composants qui fonctionnent sur un large éventail de types de données, sans sacrifier la rigueur du système de types de TypeScript.

En maîtrisant les génériques, vous serez capable de :

  • Écrire du code plus flexible et moins répétitif.
  • Développer des bibliothèques et des frameworks robustes.
  • Prévenir une multitude d'erreurs liées aux types au moment de la compilation, et non à l'exécution.
  • Mieux comprendre et utiliser les bibliothèques tierces populaires (comme React avec useState<T>, ou des bibliothèques d'APIs) qui utilisent intensivement les génériques.

Les génériques sont omniprésents dans l'écosystème TypeScript. Les comprendre est une étape cruciale pour devenir un développeur TypeScript compétent et pour développer des applications web véritablement robustes et scalables.