TypeScript Generics par des exemples concrets

1. Aperçu

Les generics sont un outil qui vous aide à écrire du code réutilisable tout en conservant la type safety. Au lieu d’écrire UserRepository, OrderRepository, ProductRepository répétitivement, vous écrivez un Repository<T> partagé. Cet article passe en revue des exemples concrets : generic function, generic class, constraints, multiple type parameters, et comment les combiner avec les utility types. Après la lecture, vous serez confiant pour appliquer les generics aux couches service, repository, API response de votre projet TypeScript.

2. Qu’est-ce que les generics et pourquoi les utiliser

Les generics vous permettent de « paramétrer » les types, comme une fonction reçoit des paramètres mais au niveau du type.

// Sans generics : vous devez réécrire pour chaque type
function firstString(arr: string[]): string {
  return arr[0];
}
function firstNumber(arr: number[]): number {
  return arr[0];
}

// Avec generics : une fonction pour tous les types
function first<T>(arr: T[]): T {
  return arr[0];
}

const name = first(["Alice", "Bob"]); // T = string, inférence automatique
const age = first([30, 25]);          // T = number

<T> est le type parameter — une variable représentant le type réel qui sera passé lors de l’appel. TypeScript infère automatiquement T en fonction de l’argument, vous n’avez pas besoin de le spécifier manuellement.

Note : T, U, K, V ne sont que des conventions de nommage, sans signification particulière. Avec une codebase volumineuse, des noms plus explicites comme TItem, TResponse sont beaucoup plus lisibles.

3. Generic function dans la pratique : la fonction groupBy

Un exemple courant lors du traitement des données : grouper des objets selon un champ.

interface Order {
  id: number;
  status: "pending" | "paid" | "cancelled";
  customerId: number;
}

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) {
    const key = keyFn(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const orders: Order[] = [
  { id: 1, status: "paid", customerId: 10 },
  { id: 2, status: "pending", customerId: 11 },
  { id: 3, status: "paid", customerId: 10 },
];

const byStatus = groupBy(orders, (o) => o.status);
  • T est le type de l’élément dans le tableau (Order).
  • K extends string | number limite la clé à être seulement string ou number — car une clé d’objet ne peut pas être d’un autre type.
  • Le résultat byStatus a le type Record<"pending" | "paid" | "cancelled", Order[]>, TypeScript connaît exactement les clés possibles.
byStatus.paid       -> Order[] (2 éléments)
byStatus.pending    -> Order[] (1 élément)
byStatus.cancelled  -> Order[] (peut être undefined si strict)

4. Generic constraints avec extends

Une constraint limite T pour qu’il respecte une certaine shape, vous permettant d’accéder à des propriétés de manière sûre dans la generic function.

interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

interface Customer extends HasId {
  name: string;
  email: string;
}

const customers: Customer[] = [
  { id: 1, name: "An", email: "an@example.com" },
  { id: 2, name: "Bình", email: "binh@example.com" },
];

const found = findById(customers, 1); // Customer | undefined

Sans extends HasId, la ligne item.id génère une erreur car TypeScript ne sait pas si T a la propriété id ou non.

error TS2339: Property 'id' does not exist on type 'T'.

C’est l’erreur la plus courante en apprenant les generics : oublier la constraint puis accéder arbitrairement à une propriété sur T.

5. Generic class : l’exemple Repository<T>

C’est un cas d’usage classique — un repository generic utilisé pour plusieurs entités.

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();

  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  findById(id: number): T | undefined {
    return this.items.get(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  deleteById(id: number): boolean {
    return this.items.delete(id);
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "Clavier", price: 500_000 });
productRepo.save({ id: 2, name: "Souris", price: 200_000 });

const all = productRepo.findAll(); // Product[]
  • Repository<T extends Entity> garantit que toute entité gérée a id, donc Map<number, T> utilisant id comme clé est sûr.
  • Lorsque vous déclarez new Repository<Product>(), chaque méthode retourne exactement le type Product — l’IDE propose l’autocomplétion pour name, price.

Note : Si vous n’utilisez Repository que pour une seule entité dans tout le projet, les generics sont de l’over-engineering. Écrivez d’abord une classe concrète, et ne la rendre générique que lorsqu’il y a ≥ 2 endroits partageant la même logique.

6. Multiple type parameters : l’exemple ApiResponse<TData, TError>

Lorsque vous avez besoin de paramétrer plus d’un type, vous utilisez plusieurs type parameters à la fois.

interface ApiResponse<TData, TError = string> {
  success: boolean;
  data?: TData;
  error?: TError;
}

function fetchSuccess<T>(data: T): ApiResponse<T> {
  return { success: true, data };
}

function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
  return { success: false, error };
}

interface User {
  id: number;
  username: string;
}

const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });

const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
  code: 404,
  message: "Utilisateur non trouvé",
});

TError = string à la ligne de déclaration de l’interface est un default generic type — si vous ne passez pas un deuxième type, TypeScript utilise automatiquement string. C’est pourquoi ApiResponse<User> est valide même avec un seul type argument.

7. Combinaison de generics avec les utility types

Les utility types comme Partial, Pick, Omit sont en réalité aussi des generic types définis d’avance dans TypeScript. Les combiner avec vos propres generics est très efficace pour les fonctions de mise à jour.

interface Customer {
  id: number;
  name: string;
  email: string;
  vip: boolean;
}

function updateEntity<T extends { id: number }>(
  original: T,
  patch: Partial<Omit<T, "id">>
): T {
  return { ...original, ...patch };
}

const customer: Customer = { id: 1, name: "An", email: "an@a.com", vip: false };

const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "An", email: "an@a.com", vip: true }
  • Partial<Omit<T, "id">> signifie : prendre tous les champs de T sauf id, puis rendre tous ces champs optionnels.
  • Ainsi, updateEntity n’autorise pas la modification de id, et l’appelant ne doit passer que les champs à modifier — très similaire au pattern PATCH dans les API REST.

Si vous essayez de passer { id: 2 } dans patch, TypeScript signale immédiatement une erreur car id a été exclu par Omit du type autorisé :

error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.

8. Generic avec les conditional types : quand utiliser infer

Une étape plus avancée : extraire automatiquement le type retourné par une fonction, en évitant les redondances.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

async function fetchCustomer(id: number): Promise<Customer> {
  return { id, name: "An", email: "an@a.com", vip: false };
}

type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer, pas Promise<Customer>
  • infer U demande à TypeScript « d’inférer » le type à l’intérieur de Promise<...> et de l’assigner à U.
  • Vous n’avez pas besoin de savoir d’avance ce que fetchCustomer retourne — le type est inféré automatiquement, et si la signature de fetchCustomer change, FetchResult se met à jour automatiquement.

Note : Les conditional types avec infer sont puissants mais peuvent rendre le code difficile à lire. Utilisez-les seulement lorsque vous avez besoin d’une utility type réutilisable plusieurs fois (comme UnwrapPromise), ne les abusez pas pour une logique ponctuelle.

9. Écrire des tests pour les generic functions

Les generics affectent la vérification des types lors de la compilation, mais le comportement à l’exécution nécessite toujours des tests normaux. Installez Vitest :

<!-- package.json (partie dependencies, pas pom.xml) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();
  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }
  findById(id: number): T | undefined {
    return this.items.get(id);
  }
}

interface Product extends Entity {
  name: string;
}

describe("Repository<Product>", () => {
  it("sauvegarde et retrouve correctement le produit par id", () => {
    const repo = new Repository<Product>();
    repo.save({ id: 1, name: "Clavier" });

    const found = repo.findById(1);

    expect(found).toBeDefined();
    expect(found?.name).toBe("Clavier");
  });

  it("retourne undefined lorsque l'id n'existe pas", () => {
    const repo = new Repository<Product>();

    const found = repo.findById(999);

    expect(found).toBeUndefined();
  });
});

Exécutez npx vitest run, résultat attendu :

✓ Repository<Product> > sauvegarde et retrouve correctement le produit par id
✓ Repository<Product> > retourne undefined lorsque l'id n'existe pas

Test Files  1 passed (1)
     Tests  2 passed (2)

Ce test ne vérifie pas le type (le compilateur TypeScript l’a fait lors du build), mais teste la logique runtime de la classe generic — en s’assurant que Map fonctionne correctement quel que soit T.

10. Erreurs courantes et quand ne pas utiliser les generics

  • Generic surdimensionné (over-generic) : créer Repository<T> alors que le projet n’a qu’une seule entité. Une classe concrète CustomerRepository serait plus facile à lire et à maintenir.
  • Oublier la constraint : écrire function getProp<T>(obj: T, key: string) puis accéder à obj[key] génère une erreur de type any ou d’index signature. Utilisez plutôt <T, K extends keyof T>(obj: T, key: K): T[K].
  • Utiliser any au lieu de generics : function identity(x: any): any perd complètement la type safety — TypeScript ne signale plus rien. Privilégiez toujours <T>(x: T): T.
  • Trop de type parameters génériques : si une fonction a 4-5 type parameters, le code devient très difficile à lire. Pensez à regrouper les types dans une interface config unique.
// Difficile à lire
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }

// Plus facile à lire
interface MergeInput<TBase, TExtra> {
  base: TBase;
  extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }

Note : Si un type parameter n’est utilisé qu’une seule fois dans toute la signature de fonction, vous n’avez probablement pas besoin de generic — un type concret ou unknown suffit.

11. Conclusion

Les generics vous permettent d’écrire du code réutilisable sans sacrifier la type safety : un Repository<T>, un ApiResponse<TData, TError> à la place de dizaines de classes/interfaces dupliquées. La clé est de maîtriser trois choses — les type parameters de base, les constraints avec extends, et comment les combiner avec les utility types disponibles (Partial, Omit, Pick). Ne généralisez pas tout dès le début ; ne factoriser en generic que lorsque vous voyez la logique se répéter dans au moins deux endroits différents dans la codebase.

typescriptgenericsjavascripttype-safetyfrontend