← Retour au blog
TypeScript

Générique, paramètre de type, argument de type : la vraie sémantique

Rétablir la véritable définition d'un générique en TypeScript : un type générique prend des paramètres de type, remplacés par des arguments de type inférés ou fournis.

📅 ✍️ Antoine Coulon
typescriptgenericstype-parameterstype-inferenceeffect

« Ce type a trois génériques. » La phrase paraît anodine, on l’entend dans la plupart des conférences et des discussions techniques, et je l’ai moi-même employée plus d’une fois, en connaissance de cause. Lors de mes dernières interventions autour de TypeScript, notamment le meetup Paris TypeScript et l’enregistrement du podcast Hands-On, j’ai volontairement fait cet abus de langage. La simplification peut servir la pédagogie, mais elle finit par installer une confusion qu’il vaut mieux dissiper. Il est temps de rétablir la véritable définition d’un « générique ».

Trois génériques, vraiment ?

Prenons l’exemple d’un Effect<R, E, A>, que j’ai décrit comme un data type « avec trois génériques ». Beaucoup en concluent qu’il y a, ici, trois génériques. Dans la pratique quotidienne, ce raccourci n’a guère de conséquence. Mais la précision sur les termes a son importance, ne serait-ce que pour ne pas perdre celles et ceux pour qui le sujet n’est pas encore limpide.

En réalité, il n’y a qu’un seul type générique : Effect. Ce qu’on appelle à tort « les trois génériques » sont en fait ses trois paramètres de type.

La nuance n’est pas que cosmétique. Confondre le type générique avec ses paramètres revient à confondre une fonction avec ses arguments. C’est précisément cette analogie qui permet de remettre chaque concept à sa place.

Le vocabulaire exact

Trois termes, trois rôles distincts. Une fois la frontière posée, tout le reste s’éclaire.

Le type générique

Un type générique est un type qui prend un ou plusieurs paramètres de type. C’est l’enveloppe, le modèle paramétrable. Effect, Promise, Array sont des types génériques. Pris seuls, ils sont incomplets : il leur manque l’information sur ce qu’ils contiennent ou manipulent.

Les paramètres de type

Les paramètres de type sont les emplacements déclarés entre chevrons à la définition du type. Dans Effect<R, E, A>, ce sont R, E et A. Exactement comme les paramètres d’une fonction, ils peuvent être plus ou moins stricts : d’un any totalement permissif jusqu’à une contrainte très précise, par exemple un template literal type. Ils peuvent aussi recevoir une valeur par défaut.

Les arguments de type

Les arguments de type sont les types concrets qui viennent remplacer les paramètres à l’usage. Quand on écrit :

Effect<UserRepository, UserNotFound, User>

UserRepository, UserNotFound et User sont les arguments de type substitués respectivement à R, E et A. Le parallèle avec une fonction est total : le paramètre est ce qu’on déclare, l’argument est ce qu’on passe réellement à l’appel.

L’analogie avec les fonctions

C’est le pont mental le plus efficace pour ancrer la distinction. Une fonction function f(x) { ... } déclare un paramètre x ; à l’appel f(42), 42 est l’argument. Un type générique Type<T> déclare un paramètre de type T ; à l’usage Type<string>, string est l’argument de type.

Les deux mécanismes partagent même des propriétés que l’on tient pour acquises côté fonctions :

Quand l’argument est inféré, quand il doit être fourni

Reste la question décisive : qui fournit l’argument de type ? Soit le compilateur l’infère, soit on doit le fournir explicitement. La frontière dépend de la nature de ce qu’on type.

Les primitives qui portent une valeur runtime (les fonctions, les classes) permettent à TypeScript de déduire les arguments de type à partir de cette valeur. Pas besoin de les écrire à la main.

À l’inverse, les définitions purement « type-level » (un type, une interface) n’ont aucune valeur runtime à partir de laquelle inférer. L’argument doit alors être fourni explicitement… à moins qu’un paramètre par défaut ne prenne le relais.

Le code ci-dessous illustre l’ensemble de ces cas de figure, du paramètre contraint à l’argument par défaut.

/**
 * -----------------------------------------------------------------
 * Un type générique est un type qui prend 1 ou N paramètres de type
 * -----------------------------------------------------------------
 */

// Effect est un type générique qui prend 3 paramètres de type.
interface Effect<R, E, A> {}

// Promise est un type générique qui prend 1 paramètre de type.
interface Promise<T> {}

// SomeGeneric est un type générique avec une contrainte assez forte sur son paramètre unique.
type SomeGeneric<T extends `very.constrained.parameter=${string}`> = {
  readonly value: T;
};

/**
 * -----------------------------------------------------------------------
 * Ces paramètres doivent ensuite être remplacés par des arguments de type,
 * de la même manière que les paramètres d'une fonction sont remplacés par
 * des arguments lors de l'appel de la fonction.
 * -----------------------------------------------------------------------
 */

// Les functions sont une des primitives qui permettent l'inférence de type avec TypeScript.
function someGenericFunction<T>(value: T): T {
  return value;
}

// "number" est ici inféré grâce à la valeur runtime 0, TypeScript peut donc déduire le type.
const valueWithTypeInference = someGenericFunction(0);

// Par contre pour les définitions purement type-level, il faut explicitement fournir
// l'argument, TypeScript ne peut pas inférer...
type SomeGenericWithExplicitType = SomeGeneric<"very.constrained.parameter=0">;

// ...à moins de fournir des valeurs par défaut pour les paramètres, de la même manière
// que pour les fonctions.
type SomeGenericWithDefaultValue<TypeParameterWithDefault = string> = {
  readonly value: TypeParameterWithDefault;
};

// Possibilité de ne pas fournir d'argument, TypeScript utilise alors le type par défaut.
type ManuallyProvidedStringValue = SomeGenericWithDefaultValue;

On y retrouve les trois moments clés : someGenericFunction(0) infère number depuis la valeur runtime 0, sans qu’aucun argument de type ne soit écrit ; SomeGeneric<"very.constrained.parameter=0"> exige un argument explicite faute de valeur à inférer ; et SomeGenericWithDefaultValue, doté d’un paramètre par défaut, peut être utilisé sans argument du tout.

Préférer l’inférence

Un dernier principe découle directement de ce qui précède : dès que l’inférence est possible, il faut la privilégier. Fournir manuellement un argument que le compilateur aurait pu déduire introduit une redondance, et chaque redondance est une occasion de divergence, le jour où la valeur runtime change mais pas l’annotation. Laisser TypeScript inférer préserve l’exactitude du programme et évite de dupliquer l’information. On ne fournit l’argument explicitement que là où l’inférence est réellement hors de portée, typiquement au niveau purement type-level.

En résumé

La prochaine fois que l’on vous parlera d’un type « à trois génériques », vous saurez qu’il s’agit en réalité d’un seul type générique à trois paramètres de type. Un détail de vocabulaire, certes, mais c’est souvent en nommant les choses avec justesse qu’on les comprend vraiment.