TypeScript le système de types - 06 - Generics : les vrais, pas juste Array

Comprendre les generics en TypeScript au-delà de Array. Fonctions génériques, contraintes, inference, et patterns concrets pour du code réutilisable et type-safe.

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
  • extends contraint 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, unknown suffit

Article précédent : 05 - Egalite structurelle vs referentielle

Article suivant : 07 - Discriminated unions

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.