Développement Web

Génériques avancés en TypeScript : du paramètre au conditional type

13 min de lecture

📍 Guide principal de la série : TypeScript moderne — du JS au système de types industriel

Ce tutoriel construit la maîtrise des génériques par paliers, du paramètre de fonction au conditional type avec infer.

Introduction

Les génériques sont la mécanique qui transforme TypeScript en système de types réellement industriel. Sans eux, on doit dupliquer une fonction cache pour chaque type de valeur stocké, écrire un Result par type d’erreur, dupliquer un router HTTP par modèle de payload. Avec eux, on écrit une seule signature paramétrique et le compilateur infère le bon type partout. Ce tutoriel construit la maîtrise par paliers, du paramètre élémentaire aux conditional types avec infer, en passant par les nouveautés récentes comme const type parameters et NoInfer.

Prérequis

  • Projet TypeScript fonctionnel, version 5.4 ou supérieure (les exemples utilisent jusqu’à TS 6.0).
  • Connaître les bases des types et interfaces.
  • Confort avec les fonctions et les classes TypeScript.
  • Temps estimé : 60 minutes.

Étape 1 — Le paramètre de type, une variable au niveau des types

Un générique introduit une variable au niveau des types. La fonction identity ci-dessous accepte n’importe quelle valeur et la retourne telle quelle, mais avec une signature qui préserve le type d’origine. Sans paramètre de type, on serait obligé d’écrire function identity(x: unknown): unknown, ce qui ferait perdre l’information dès l’appel.

function identity<T>(value: T): T {
  return value;
}

const n = identity(42);        // n : number
const s = identity("bonjour"); // s : string
const a = identity([1, 2, 3]); // a : number[]

TypeScript infère T à partir de l’argument passé. On peut aussi forcer l’inférence en fournissant explicitement le type entre chevrons : identity<string>("bonjour"). Cette forme explicite est utile quand l’inférence ne suffit pas, par exemple pour ouvrir un type plus large que ce que l’argument suggère, ou pour clarifier l’intention dans une factory.

Étape 2 — Contraindre un paramètre avec extends

Un paramètre de type peut être contraint, c’est-à-dire restreint à des types qui satisfont une certaine forme. La contrainte s’écrit avec extends. Elle ne change pas le fait que T reste un type spécifique : elle indique simplement que ce type doit avoir au moins la forme contrainte.

interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

logLength("hello");        // ✓ une chaîne a une length
logLength([1, 2, 3]);      // ✓ un tableau aussi
logLength({ length: 5 });  // ✓ un objet avec length
logLength(42);             // ✗ Erreur : number n'a pas de length

Les contraintes deviennent vite indispensables dès qu’on accède à une propriété de T. Sans extends Lengthwise, le compilateur refuserait arg.length parce qu’il n’a aucune garantie que T dispose de cette propriété. La contrainte joue le rôle d’un contrat passé entre l’auteur de la fonction et le compilateur.

Étape 3 — Contraindre par un autre paramètre

Une situation très courante consiste à contraindre un paramètre K à être une clé valide d’un autre paramètre T. La combinaison K extends keyof T permet d’écrire des fonctions d’accès type-safe qui refusent les clés inexistantes.

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Aïssatou", age: 31, role: "admin" };

getProperty(user, "name"); // ✓ retour : string
getProperty(user, "age");  // ✓ retour : number
getProperty(user, "city"); // ✗ Erreur : "city" pas une clé de user

L’opérateur keyof T produit l’union des clés du type, ici "name" | "age" | "role". Le type de retour T[K] est un type indexé : il représente le type de la valeur stockée à la clé K dans T. Ce duo est la base de toutes les API typées qui manipulent des champs dynamiques, des ORMs aux validateurs en passant par les routeurs.

Étape 4 — Génériques sur les interfaces et les classes

Les classes et les interfaces peuvent être paramétrées par un type. Le paramètre s’applique alors à toutes les méthodes et propriétés du conteneur. La sémantique est identique aux fonctions génériques : on déclare une variable de type au niveau de la déclaration, on l’utilise dans le corps.

interface Box<T> {
  contents: T;
  isEmpty: boolean;
}

class Stack<T> {
  private items: T[] = [];

  push(value: T): void {
    this.items.push(value);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items.at(-1);
  }
}

const numbers = new Stack<number>();
numbers.push(1);
numbers.push(2);
const top = numbers.peek(); // top : number | undefined

Note importante : avec noUncheckedIndexedAccess activé, le retour de this.items.at(-1) est bien T | undefined et non T. C’est conforme à la réalité : la méthode at peut retourner undefined si l’index est hors plage. La contrainte est portée jusque dans la signature, ce qui force tout consommateur à gérer l’absence.

Étape 5 — Valeurs par défaut pour les paramètres de type

Un paramètre de type peut avoir une valeur par défaut, ce qui rend son inférence explicite optionnelle. Cette fonctionnalité est précieuse pour les bibliothèques qui exposent un type très configurable sans imposer aux utilisateurs de le préciser à chaque usage.

interface ApiResponse<TData, TError = Error> {
  data: TData | null;
  error: TError | null;
}

function fetchUser(): ApiResponse<User> { /* ... */ }
//                              ^^^^^^^ TError vaut Error par défaut

function fetchInvoice(): ApiResponse<Invoice, ApiValidationError> { /* ... */ }
//                                          ^^^^^^^^^^^^^^^^^^^ TError explicite

Les règles à connaître : un paramètre obligatoire ne peut pas suivre un paramètre avec défaut, et la valeur par défaut doit satisfaire la contrainte éventuelle. Cette mécanique permet aussi de pré-câbler une partie de la signature à partir d’un autre paramètre, par exemple <T, K extends keyof T = keyof T>.

Étape 6 — Conditional types : T extends U ? X : Y

Les conditional types introduisent une ramification au niveau du système de types. La syntaxe ressemble à un opérateur ternaire et permet de produire un type différent selon que T est ou non assignable à U.

type IsString<T> = T extends string ? "oui" : "non";

type A = IsString<"abc">;  // "oui"
type B = IsString<42>;      // "non"
type C = IsString<string>;  // "oui"

Le mécanisme prend une dimension supplémentaire avec les unions. Quand T est une union et qu’il apparaît nu (naked) du côté gauche de extends, le conditional type se distribue sur chaque membre de l’union. C’est la distributivité, mécanisme à la base de Exclude et Extract.

type ToArray<T> = T extends any ? T[] : never;

type R = ToArray<string | number>;
// Équivaut à : ToArray<string> | ToArray<number>
// Soit : string[] | number[]

// Pour empêcher la distributivité, on enveloppe dans un tuple :
type ToArrayNon<T> = [T] extends [any] ? T[] : never;
type R2 = ToArrayNon<string | number>;
// (string | number)[]

La distinction entre distributif et non distributif n’est pas un détail ésotérique : elle change radicalement le résultat du type. La connaître évite des heures de débogage sur des bibliothèques qui font de la composition de types intensive.

Étape 7 — infer : extraire un sous-type

À l’intérieur d’un conditional type, le mot-clé infer introduit une variable supplémentaire qui capture une partie du type testé. C’est la mécanique qui permet d’extraire un sous-type sans le connaître à l’avance, et qui sous-tend la plupart des utility types standards comme ReturnType ou Parameters.

type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;

type R1 = MyReturnType<() => string>;            // string
type R2 = MyReturnType<(a: number) => boolean>;  // boolean
type R3 = MyReturnType<42>;                      // never (pas une fonction)

type MyParameters<F> = F extends (...args: infer P) => any ? P : never;
type P1 = MyParameters<(a: string, b: number) => void>;
// [a: string, b: number]

Le pattern se généralise à l’extraction d’éléments dans des tuples, des promesses, des éléments de tableau ou des constructeurs. Awaited<P>, ajouté en TypeScript 4.5, fait exactement cela pour les chaînes de promesses imbriquées.

type Head<T extends readonly any[]> = T extends [infer H, ...any[]] ? H : never;
type Tail<T extends readonly any[]> = T extends [any, ...infer R] ? R : never;

type H = Head<[1, 2, 3]>; // 1
type T = Tail<[1, 2, 3]>; // [2, 3]

Étape 8 — Tuples variadiques

Les tuples variadiques permettent d’utiliser ...T dans la position d’un élément, où T est un type de tuple. Cette mécanique permet d’écrire des signatures qui composent des tuples avec une précision impossible avant TypeScript 4.0.

type Concat<A extends any[], B extends any[]> = [...A, ...B];

type C1 = Concat<[1, 2], [3, 4]>;
// [1, 2, 3, 4]

function tuple<T extends any[]>(...args: T): T {
  return args;
}

const t = tuple(1, "a", true);
// t : [number, string, boolean]

Combinées à infer, les tuples variadiques permettent d’écrire des transformations puissantes — inversion, rotation, recherche d’un élément, mapping élément par élément. Ces patterns dépassent rarement le code de bibliothèque mais ils expliquent comment des outils comme Zod ou tRPC parviennent à typer avec autant de précision.

Étape 9 — const type parameters (TS 5.0)

Les const type parameters changent la stratégie d’inférence du paramètre : au lieu d’élargir aux types généraux (string, number, boolean), le compilateur préserve les littéraux comme s’il avait reçu as const. C’est particulièrement utile pour les API qui produisent des types dérivés à partir d’une configuration littérale.

function getNamesExactly<const T extends { names: readonly string[] }>(arg: T): T["names"] {
  return arg.names;
}

const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });
// Sans 'const' : readonly string[]
// Avec 'const' : readonly ["Alice", "Bob", "Eve"]

Le bénéfice apparaît quand on utilise names ailleurs : avec la version const, on dispose des littéraux exacts, ce qui permet de typer des dépendances ultérieures avec précision. Sans const, on aurait perdu l’information dès la première étape.

Étape 10 — NoInfer (TS 5.4)

Quand une fonction a plusieurs paramètres qui dépendent du même type, TypeScript essaie de l’inférer à partir de chacun. Cela pose problème quand on veut qu’un paramètre soit considéré comme une valeur par défaut et non comme une source d’inférence. NoInfer<T> introduit cette distinction.

function createStreetLight<C extends string>(
  colors: C[],
  defaultColor?: NoInfer<C>
) {
  /* ... */
}

createStreetLight(["red", "yellow", "green"], "red");  // ✓
createStreetLight(["red", "yellow", "green"], "blue"); // ✗ Erreur
//                                              ~~~~~ 'blue' n'est pas une couleur valide

Sans NoInfer, TypeScript élargirait C à "red" | "yellow" | "green" | "blue" en se basant sur la deuxième argument, ce qui ferait passer l’appel à tort. NoInfer bloque cette inférence et impose que defaultColor soit choisi parmi les couleurs déjà déclarées dans colors.

Étape 11 — Cas réel : fonction map typée

Pour rassembler les concepts, on construit une fonction map sur objet, qui transforme chaque valeur en préservant les clés. Le résultat est un type calculé par Record et par un mapping sur keyof T.

function mapValues<T extends Record<string, unknown>, U>(
  obj: T,
  fn: (value: T[keyof T], key: keyof T) => U
): { [K in keyof T]: U } {
  const result = {} as { [K in keyof T]: U };
  for (const key of Object.keys(obj) as (keyof T)[]) {
    result[key] = fn(obj[key], key);
  }
  return result;
}

const lengths = mapValues({ a: "abc", b: "hello" }, (v) => v.length);
// lengths : { a: number; b: number }

Le type de retour { [K in keyof T]: U } est un mapped type qui parcourt les clés de T et leur associe U comme valeur. Le compilateur sait que lengths.a est un number et que lengths.b existe, parce que la signature préserve la structure de l’objet d’entrée.

Étape 12 — Vérification

On termine en lançant le compilateur en mode strict sur tous les exemples pour s’assurer qu’aucun avertissement ne traîne.

npx tsc --noEmit --strict

Si la sortie est silencieuse, c’est que tous les types se composent correctement et que les inférences fonctionnent comme attendu. On peut alors mettre en place un fichier de tests de type avec type Test = X extends Y ? true : false pour formaliser les attentes, ce qui prévient les régressions silencieuses dans les futures évolutions du code.

Erreurs fréquentes

Symptôme Cause probable Solution
Type 'unknown' is not assignable Paramètre générique non contraint utilisé comme un type plus précis Ajouter une contrainte extends ou narrower explicitement
L’inférence donne un type trop large Élargissement automatique des littéraux Utiliser const type parameter ou as const sur l’argument
Un conditional type ne se distribue pas comme prévu T n’est pas nu côté gauche de extends Sortir la condition ou envelopper dans un tuple [T] extends [U]
Message Type instantiation is excessively deep Récursion infinie sur un mapped type ou conditional type Plafonner la profondeur ou refactoriser en utility plus simple
infer capture toujours unknown La position de l’infer n’est pas atteinte par le type testé Vérifier que la forme de U correspond à T à la position de l’infer

Tutoriels complémentaires

FAQ

Quand utiliser any vs unknown dans une contrainte ?
extends any n’apporte presque rien : la contrainte est triviale et la distributivité est activée si T est nu. extends unknown est équivalent en pratique. On préfère écrire la contrainte la plus stricte possible (extends object, extends string, etc.) pour avoir des messages d’erreur lisibles.

Quelle différence entre T extends U et T : U dans une signature ?
Il n’y a pas de syntaxe T : U en TypeScript. Toutes les contraintes passent par extends. Le mot-clé est unique mais il n’a pas la même sémantique que l’héritage de classe : il signifie « assignable à », pas « hérite de ».

Les génériques sont-ils effacés à l’exécution ?
Oui, comme tout TypeScript. Le code émis est du JavaScript sans aucune trace des paramètres de type. Si vous avez besoin du type à l’exécution, il faut le passer comme valeur (par exemple un schéma Zod) ou un constructeur de classe.

Comment tester un type sans l’exécuter ?
On écrit des assertions de type avec un utilitaire comme type Expect<T extends true> = T et type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false. Le projet Type Challenges fournit ces utilitaires.

Pourquoi const type parameters n’est pas le défaut ?
Parce que le comportement existant est attendu par des millions de lignes de code. Activer le comportement const partout casserait des inférences déjà tenues pour acquises. L’opt-in const permet une adoption progressive.

Ressources et références

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité