Pourquoi any fait-il perdre le type que vous avez passé ?

Typez un paramètre en any et TypeScript arrête de vérifier tout ce qui sort de la fonction :

1
2
3
4
5
6
function firstElement(arr: any[]): any {
  return arr[0];
}

const n = firstElement([1, 2, 3]);
n.toUpperCase(); // compile, plante à l'exécution

Vous savez que vous avez passé un tableau de nombres. Le compilateur, non, car any lui dit d’arrêter de regarder. n est de type any, donc appeler une méthode de string sur un number compile sans problème et lève TypeError: n.toUpperCase is not a function à l’exécution.

Un generic nomme le type au lieu de le jeter :

1
2
3
4
5
6
7
function firstElement<T>(arr: T[]): T {
  return arr[0];
}

const n = firstElement([1, 2, 3]); // n: number
const s = firstElement(["a", "b"]); // s: string
n.toUpperCase(); // erreur : Property 'toUpperCase' does not exist on type 'number'

T est un paramètre de type, un espace réservé rempli à chaque appel. firstElement([1, 2, 3]) remplit T avec number, le type de retour devient number, et l’appel fautif à .toUpperCase() échoue dans votre éditeur plutôt qu’en production. Vous écrivez la fonction une seule fois et chaque type d’élément garde sa vérification.

Le reste de ce guide explique comment les utiliser sans rendre la signature plus difficile à lire que le bug qu’ils évitent.

Comment TypeScript infère-t-il le paramètre de type ?

Vous n’avez pas écrit firstElement<number>([1, 2, 3]) ci-dessus, et vous en avez rarement besoin. TypeScript compare l’argument à la forme déclarée du paramètre (T[]) et résout T. C’est la même inférence qui type const x = 5 en number, appliquée aux paramètres de type.

Sans rien à partir de quoi inférer, T retombe sur unknown :

1
2
3
4
5
6
function makeEmptyArray<T>(): T[] {
  return [];
}

const arr = makeEmptyArray(); // T est unknown, inféré comme `unknown[]`
const nums = makeEmptyArray<number>(); // argument de type explicite, nums: number[]

Quand l’appel n’a aucune valeur à partir de laquelle inférer, passez vous-même l’argument de type entre chevrons.

L’inférence peut aussi mal se passer quand deux arguments alimentent le même T et se contredisent. TypeScript choisit un candidat et signale l’autre argument comme le problème, ce qui peut produire une erreur qui nomme un type que vous n’avez jamais écrit :

1
2
3
4
5
6
function longest<T>(a: T, b: T): T {
  return a;
}

longest([1, 2], "abc");
// erreur : Argument of type 'number[]' is not assignable to parameter of type '"abc"'

Quand un appel générique donne une erreur confuse ou un type inattendu, survolez l’appel dans votre éditeur et regardez ce qui a été inféré pour T. Cela explique généralement le problème plus vite que la lecture du message.

Comment contraindre un generic avec extends et keyof ?

Un T sans contrainte peut être n’importe quoi, donc dans la fonction vous ne pouvez faire que ce que tout type supporte, ce qui n’est presque rien au-delà de faire circuler la valeur. La plupart des vraies fonctions génériques doivent supposer quelque chose sur T, et extends permet de le préciser :

1
2
3
4
5
6
7
8
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const dev = { name: "Tobias", role: "backend", salary: 95000 };

getProperty(dev, "role"); // "backend", typé en string
getProperty(dev, "pay"); // erreur : Argument of type '"pay"' is not assignable to parameter of type '"name" | "role" | "salary"'

K extends keyof T limite K aux noms de propriétés de l’objet que vous passez, et T[K] lit le type de cette propriété. La liste des clés valides vient de l’objet au point d’appel, pas d’une union écrite à la main. Une faute de frappe comme "pay" devient une erreur de compilation plutôt qu’un undefined silencieux.

La contrainte peut aussi être une forme simple :

1
2
3
4
5
6
7
8
function logLength<T extends { length: number }>(value: T): T {
  console.log(value.length);
  return value;
}

logLength("hello"); // 5
logLength([1, 2, 3]); // 3
logLength(42); // erreur : Argument of type 'number' is not assignable to parameter of type '{ length: number; }'

Les strings et les tableaux ont tous deux une length, donc les deux passent. Un number non, et l’appel est rejeté avant même que le corps de la fonction s’exécute. Comme le type de retour est T et non { length: number }, logLength("hello") vous renvoie bien une string.

Comment fonctionnent les interfaces et classes génériques ?

Une interface générique est une forme avec un paramètre de type :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
interface Box<T> {
  value: T;
  unwrap(): T;
}

const numberBox: Box<number> = {
  value: 5,
  unwrap() {
    return this.value;
  },
};

Une classe générique partage son paramètre de type entre tous ses champs et méthodes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Stack<T> {
  private items: T[] = [];

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

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

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

const numbers = new Stack<number>();
numbers.push(1);
numbers.push(2);
numbers.peek(); // 2, typé en number | undefined

new Stack<number>() et new Stack<string>() sont tous les deux entièrement typés, et la classe n’existe qu’une seule fois. Le même schéma se retrouve partout où vous utilisez l’injection de dépendances : vous écrivez un Repository<T> une fois, puis vous injectez Repository<User> et Repository<Order> là où ils sont nécessaires, au lieu de maintenir une classe presque identique par entité.

Plusieurs paramètres de type fonctionnent de la même façon, et chacun peut avoir sa propre contrainte :

1
2
3
4
5
function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

pair("id", 42); // [string, number]

Deux paramètres, c’est aussi la façon dont un type Result<T, E> porte côte à côte une valeur de succès et un type d’erreur. Si vous hésitez entre lever des exceptions ou renvoyer les échecs comme des données, exceptions vs valeurs d’erreur détaille ce choix.

Comment fonctionnent les paramètres de type par défaut ?

Un paramètre de type peut avoir une valeur par défaut, comme un paramètre de fonction avec une valeur par défaut :

1
2
3
4
5
6
class ApiResponse<T = unknown> {
  constructor(public data: T, public status: number) {}
}

const raw = new ApiResponse({ ok: true }, 200); // T inféré comme { ok: boolean }
const typed: ApiResponse<{ id: number }> = new ApiResponse({ id: 1 }, 200);

L’inférence gagne toujours quand il y a un argument à partir duquel inférer, comme le montre raw. La valeur par défaut s’applique quand rien ne remplit T, par exemple quand vous écrivez ApiResponse comme annotation de type sans argument de type. Cela rend les valeurs par défaut utiles pour des wrappers comme les types de réponse et les payloads d’événements, où beaucoup de points d’appel se soucient peu du type exact des données. Prenez unknown par défaut, pas any : cela force toujours une vérification avant que quelqu’un ne lise data.

Quand utiliser un union type au lieu d’un generic ?

Posez-vous une question avant d’ajouter <T> : le type de retour dépend-il de ce qui a été passé ? Si la fonction renvoie toujours le même type quelle que soit l’entrée, une union suffit et un generic n’apporte rien :

1
2
3
4
// pas besoin de generic ici : le type de retour ne suit pas l'entrée
function describe(value: string | number): string {
  return typeof value === "string" ? value.toUpperCase() : value.toFixed(2);
}

firstElement, getProperty et Stack<T> sont différents. Chacun renvoie quelque chose dont le type dépend de ce qu’on lui passe, et ce lien entre entrée et sortie est ce qu’un generic vous apporte. Si vous pouvez écrire la fonction avec une union sans rien perdre, utilisez l’union.

Empiler des paramètres de type parce qu’une fonction pourrait être générique coûte en lisibilité et n’apporte rien. Un helper avec trois paramètres de type qui n’est jamais appelé qu’avec string et number est plus difficile à lire que deux surcharges. Ajoutez <T> quand un appelant réel a besoin qu’un type circule, et retirez-le quand chaque point d’appel fixe le même type concret.

Pourquoi ne peut-on pas utiliser T à l’exécution ?

Les paramètres de type sont effacés à la compilation. Le JavaScript qui s’exécute ne contient aucun T, donc vous ne pouvez pas en construire un, vérifier instanceof T, ni faire de branchement en fonction de lui :

1
2
3
4
function create<T>(): T {
  // return new T(); // erreur : 'T' only refers to a type, but is being used as a value here
  throw new Error("not implemented");
}

Si une fonction doit construire un T ou l’inspecter à l’exécution, passez une valeur réelle en argument : un constructeur, un schéma, ou une étiquette de type. Les generics sont un contrat à la compilation, pas une garantie à l’exécution, donc les données venant de l’extérieur de votre programme (une réponse d’API, JSON.parse) doivent toujours être validées même quand elles sont typées ApiResponse<User>.

Pour voir chaque erreur de ce guide par vous-même, installez TypeScript avec le gestionnaire que votre projet utilise parmi npm, Yarn ou pnpm, collez les exemples dans un fichier .ts et lancez npx tsc --noEmit --strict. Ouvrez ensuite l’any le plus utilisé dans votre propre code et vérifiez si un <T> ferait circuler le type.

Articles associés