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,Vne sont que des conventions de nommage, sans signification particulière. Avec une codebase volumineuse, des noms plus explicites commeTItem,TResponsesont 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);
Test le type de l’élément dans le tableau (Order).K extends string | numberlimite la clé à être seulementstringounumber— car une clé d’objet ne peut pas être d’un autre type.- Le résultat
byStatusa le typeRecord<"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 aid, doncMap<number, T>utilisantidcomme clé est sûr.- Lorsque vous déclarez
new Repository<Product>(), chaque méthode retourne exactement le typeProduct— l’IDE propose l’autocomplétion pourname,price.
Note : Si vous n’utilisez
Repositoryque 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 deTsaufid, puis rendre tous ces champs optionnels.- Ainsi,
updateEntityn’autorise pas la modification deid, 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 Udemande à TypeScript « d’inférer » le type à l’intérieur dePromise<...>et de l’assigner àU.- Vous n’avez pas besoin de savoir d’avance ce que
fetchCustomerretourne — le type est inféré automatiquement, et si la signature defetchCustomerchange,FetchResultse met à jour automatiquement.
Note : Les conditional types avec
infersont puissants mais peuvent rendre le code difficile à lire. Utilisez-les seulement lorsque vous avez besoin d’une utility type réutilisable plusieurs fois (commeUnwrapPromise), 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èteCustomerRepositoryserait 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 typeanyou d’index signature. Utilisez plutôt<T, K extends keyof T>(obj: T, key: K): T[K]. - Utiliser
anyau lieu de generics :function identity(x: any): anyperd 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
unknownsuffit.
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.