06 - Generics : les vrais, pas juste Array
Ce que tu vas apprendre
- Ce que sont les generics et pourquoi ils remplacent
any - Comment écrire des fonctions, interfaces et classes génériques
- Comment contraindre un generic avec
extends - Les patterns courants : generics dans les API, les repositories, les hooks
Prerequisites
Avoir lu les articles précédents, en particulier any, unknown, never et inference.
Le wrapper qui perd le type
Un pattern que je vois souvent chez les juniors. Tu veux écrire une fonction utilitaire qui enveloppe un appel API :
typescriptasync function fetchJson(url: string): Promise<any> {
const res = await fetch(url)
return res.json()
}
const user = await fetchJson("/api/users/1")
user.name // type: any — aucune aide du compilateur
Le any dans le retour détruit toute l'information de type. Tu as écrit du TypeScript mais tu recois du JavaScript. Le reflexe classique : caster le retour.
typescriptconst user = await fetchJson("/api/users/1") as User
Ca fonctionne mais c'est un mensonge au compilateur. Si l'API change et que le champ name disparaît, le cast as User continuera de compiler et tu auras un crash au runtime.
La bonne solution, c'est un generic.
Le generic : un type en paramètre
Un generic, c'est un type que tu ne connais pas a l'avance et que le code appelant fournit. Comme un paramètre de fonction, mais pour les types.
typescriptasync function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url)
return res.json()
}
const user = await fetchJson<User>("/api/users/1")
// type: User — le compilateur sait
user.name // ✅ autocompletion
<T> déclaré un paramètre de type. L'appelant passe User comme argument de type, et le retour devient Promise<User>. Le type circule à travers la fonction.
C'est toujours un "trust me" envers l'API (le JSON pourrait ne pas correspondre a User). Pour une vraie validation runtime, il faut Zod (couvert dans la sous-serie pratique). Mais au moins le type est explicite et tracable.
Inference des generics
Dans beaucoup de cas, tu n'as pas besoin de passer le type explicitement. TypeScript le deduit des arguments :
typescriptfunction first<T>(items: T[]): T | undefined {
return items[0]
}
const n = first([1, 2, 3]) // T infere comme number
const s = first(["a", "b"]) // T infere comme string
const u = first([]) // T infere comme unknown
Le compilateur regarde le type de items et en deduit T. Pas besoin d'écrire first<number>([1, 2, 3]).
L'inference fonctionne aussi avec plusieurs paramètres de type :
typescriptfunction pair<A, B>(first: A, second: B): [A, B] {
return [first, second]
}
const p = pair("hello", 42) // type: [string, number]
Contraindre un generic avec extends
Un generic sans contrainte accepte n'importe quoi. Parfois tu veux limiter les types acceptes.
typescript// Sans contrainte — trop permissif
function getId<T>(item: T): number {
return item.id // ❌ Property 'id' does not exist on type 'T'
}
// Avec contrainte — T doit avoir un id
function getId<T extends { id: number }>(item: T): number {
return item.id // ✅
}
getId({ id: 1, name: "Alice" }) // ✅
getId({ id: 2, email: "b@b.fr" }) // ✅
getId({ name: "Charlie" }) // ❌ Property 'id' is missing
getId(42) // ❌ number n'a pas de propriete 'id'
T extends { id: number } signifie : "T peut etre n'importe quel type, tant qu'il a au moins une propriété id de type number". Le compilateur le vérifié a chaque appel.
Contraintes avec des types existants
Tu peux utiliser des interfaces comme contraintes :
typescriptinterface HasTimestamps {
createdAt: Date
updatedAt: Date
}
function getAge<T extends HasTimestamps>(item: T): number {
return Date.now() - item.createdAt.getTime()
}
Ou combiner plusieurs contraintes :
typescriptfunction merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b }
}
const result = merge({ name: "Nicolas" }, { age: 31 })
// type: { name: string } & { age: number }
Generics dans les interfaces et types
Les generics ne sont pas reserves aux fonctions. Ils fonctionnent sur les interfaces, les types aliases et les classes.
typescript// Interface generique
interface ApiResponse<T> {
data: T
status: number
timestamp: Date
}
const userResponse: ApiResponse<User> = {
data: { id: 1, name: "Nicolas", email: "n@n.dev" },
status: 200,
timestamp: new Date()
}
const listResponse: ApiResponse<User[]> = {
data: [{ id: 1, name: "Nicolas", email: "n@n.dev" }],
status: 200,
timestamp: new Date()
}
Un type pour les résultats pagines :
typescriptinterface Paginated<T> {
items: T[]
total: number
page: number
perPage: number
hasNext: boolean
}
type UserPage = Paginated<User>
type OrderPage = Paginated<Order>
Tu ecris la structure une fois, tu l'appliques a n'importe quel type. C'est ce qui rend les generics supérieurs a any : le type se propage.
Generics avec des valeurs par défaut
Tu peux donner une valeur par défaut a un generic :
typescriptinterface Store<T = Record<string, unknown>> {
get(key: string): T | undefined
set(key: string, value: T): void
}
const store: Store = { /* ... */ } // T = Record<string, unknown>
const userStore: Store<User> = { /* ... */ } // T = User
C'est utile pour les types qui ont un cas d'usage "général" mais qui peuvent etre specialises.
Pattern : le repository générique
Un pattern que j'utilise sur paltemps.fr et dans les projets clients :
typescriptinterface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>
findAll(): Promise<T[]>
create(data: Omit<T, "id">): Promise<T>
update(id: string, data: Partial<T>): Promise<T>
delete(id: string): Promise<void>
}
Chaque entité a son repository type :
typescriptclass UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null> {
return prisma.user.findUnique({ where: { id } })
}
// ... les autres methodes
}
Le compilateur vérifié que chaque méthode utilise le bon type. Si User n'a pas de propriété id: string, le implements Repository<User> refuse de compiler.
L'article sur le pattern Repository dans la serie Design Patterns couvre ce sujet en détail.
Generics multiples et keyof
Un pattern avance : une fonction qui lit une propriété d'un objet de facon type-safe.
typescriptfunction getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { name: "Nicolas", age: 31, admin: true }
const name = getProperty(user, "name") // type: string
const age = getProperty(user, "age") // type: number
const admin = getProperty(user, "admin") // type: boolean
getProperty(user, "email") // ❌ "email" n'est pas dans keyof User
Deux generics : T pour le type de l'objet, K pour la clé. K extends keyof T garantit que la clé existe dans l'objet. Le retour T[K] est le type de la propriété correspondante. L'article sur keyof et typeof approfondit ces index access types.
Erreurs courantes avec les generics
Ne pas contraindre quand il faut
typescript// ❌ T est trop large
function clone<T>(obj: T): T {
return { ...obj } // erreur si T est un primitif
}
// ✅ Contraindre a object
function clone<T extends object>(obj: T): T {
return { ...obj }
}
Trop de generics
typescript// ❌ Illisible
function transform<T, U, V, W>(input: T, mapFn: (a: U) => V, filterFn: (b: V) => W): W[]
// ✅ Simplifie
function transform<In, Out>(items: In[], fn: (item: In) => Out): Out[]
Si tu as plus de 2-3 generics, c'est un signal que ta fonction fait trop de choses.
Generic inutile
typescript// ❌ T n'apporte rien
function log<T>(value: T): void {
console.log(value)
}
// ✅ unknown suffit
function log(value: unknown): void {
console.log(value)
}
Un generic est utile quand le type de sortie depend du type d'entree. Si le retour est void ou un type fixe, tu n'as pas besoin de generic.
Résumé
- Les generics sont des paramètres de type qui rendent le code réutilisable sans sacrifier le typage
- TypeScript infere souvent le generic à partir des arguments — pas besoin de le passer explicitement
extendscontraint un generic a un sous-ensemble de types- Les generics fonctionnent sur les fonctions, interfaces, types et classes
- Utilise des generics quand le type de sortie depend du type d'entree — sinon,
unknownsuffit
Article précédent : 05 - Egalite structurelle vs referentielle
Article suivant : 07 - Discriminated unions