Types avancés
Génériques, types conditionnels, mapped types, types utilitaires.
Vérifié en septembre 2026 · TypeScript 6.0 · environ 13 min
Le système de types de TypeScript est un petit langage qui calcule sur des types : il teste, il boucle sur des clés, il découpe des chaînes. Son intérêt pratique est de n'écrire une forme qu'une fois et d'en dériver toutes les autres — le formulaire d'un modèle, ses gestionnaires d'événements, sa version publique sans mot de passe — si bien qu'un champ ajouté à l'interface se propage partout, et que chaque endroit oublié devient une erreur de compilation. Pour un développeur C#, deux différences dominent. Les types sont structurels : une valeur convient dès qu'elle a la bonne forme, quel que soit son nom. Et ils sont effacés : rien de ce qui suit ne survit à la compilation, aucun typeof(T) n'existe à l'exécution.
Génériques et contraintes
Un générique TypeScript ressemble à celui de C# par la syntaxe et s'en éloigne par le fond. Le paramètre de type est effacé : new T() ne compile pas, faute de valeur derrière le nom ('T' only refers to a type, but is being used as a value here). La contrainte extends ne parle pas d'héritage mais d'assignabilité : T extends { prix: number } accepte tout objet qui porte un prix numérique, qu'il ait été déclaré comme tel ou non.
La contrainte la plus utile est K extends keyof T. keyof T est l'union des noms de propriétés de T, sous forme de littéraux, et T[K] — un type d'accès indexé — le type de la valeur rangée sous K. Sans la contrainte, element[cle] est refusé, parce que rien ne garantit que K soit une clé : l'erreur TS2536 le dit.
interface Produit {
reference: string;
libelle: string;
prix: number;
}
// K extends keyof T : K ne peut etre qu'une cle de T. T[K] designe alors le
// type de la valeur rangee sous cette cle, et il suit K d'un appel a l'autre.
function parCle<T, K extends keyof T>(elements: readonly T[], cle: K): Map<T[K], T> {
const index = new Map<T[K], T>();
for (const element of elements) {
index.set(element[cle], element);
}
return index;
}
const produits: Produit[] = [
{ reference: 'USB-64', libelle: 'Cle USB 64 Go', prix: 12 },
{ reference: 'SSD-1T', libelle: 'Disque SSD 1 To', prix: 89 },
];
// Rien entre chevrons : T est deduit du tableau, K du litteral passe en
// second argument.
const parReference = parCle(produits, 'reference'); // Map<string, Produit>
const parPrix = parCle(produits, 'prix'); // Map<number, Produit>
console.log(parReference.get('SSD-1T')?.prix); // 89
console.log(parPrix.get(12)?.libelle); // Cle USB 64 Go
// parCle(produits, 'poids');
// error TS2345: Argument of type '"poids"' is not assignable to parameter of type 'keyof Produit'.
// Les arguments de type se donnent tous ou aucun.
// parCle<Produit>(produits, 'prix');
// error TS2558: Expected 2 type arguments, but got 1.
// Un parametre par defaut sert quand aucun argument ne permet de deduire le
// type. Sans lui, lister() rendrait unknown[].
function lister<T = string>(...elements: T[]): T[] {
return elements;
}
const vide = lister(); // string[]
const nombres = lister(1, 2); // number[] : l'inference passe avant le defaut L'inférence part des arguments, à chaque appel, et c'est pourquoi on n'écrit presque jamais de chevrons. Elle a une limite qui surprend : hors paramètres pourvus d'un défaut, les arguments de type se donnent tous ou aucun, et préciser T en espérant faire deviner K échoue. Un paramètre par défaut ne vaut que faute de mieux — tout candidat trouvé dans les arguments l'emporte sur lui.
L'erreur la plus fréquente avec une contrainte a un message déroutant : rendre { prix: 0 } d'une fonction typée T, avec T extends { prix: number }, est refusé, car T « could be instantiated with a different subtype ». Le compilateur a raison : l'appelant a pu passer un Produit complet et attend un Produit en retour, pas un objet qui a seulement un prix. La contrainte borne ce que l'on reçoit ; elle ne dit pas que n'importe quelle valeur conforme ferait l'affaire en sortie.
Types conditionnels
T extends U ? X : Y est un ternaire que le compilateur évalue : si T est assignable à U, le résultat est X, sinon Y. Tant que T est encore un paramètre non résolu, le type reste en suspens ; il se calcule à l'instanciation.
La propriété qui fait leur puissance est la distribution. Quand le type testé est un paramètre nu — seul, sans rien autour, à gauche de extends — et qu'il reçoit une union, le conditionnel s'applique à chaque membre séparément et les résultats sont réunis. boolean étant lui-même l'union true | false, il distribue aussi. Associée à never, qui disparaît de toute union, la distribution devient un filtre : c'est toute la définition d'Exclude.
// Un ternaire evalue par le compilateur : si T est assignable au type teste,
// le resultat est la premiere branche, sinon la seconde.
type NomDuType<T> = T extends string
? 'chaine'
: T extends number
? 'nombre'
: T extends undefined
? 'indefini'
: 'objet';
type A = NomDuType<'bonjour'>; // 'chaine' : un litteral est assignable a string
type B = NomDuType<Date>; // 'objet'
// T est ici un parametre nu, pose seul a gauche d'extends. Recu sous forme
// d'union, il est evalue membre par membre, et les resultats sont reunis.
type C = NomDuType<string | number | undefined>; // 'chaine' | 'nombre' | 'indefini'
// C'est ce qui permet de filtrer une union : never, l'union vide, disparait
// de toute union ou il apparait.
type SansNull<T> = T extends null | undefined ? never : T;
type D = SansNull<string | null | undefined>; // string
// Exclude et Extract sont ecrits exactement ainsi dans lib.es5.d.ts.
type Statut = 'brouillon' | 'publie' | 'archive';
type Visible = Exclude<Statut, 'archive'>; // 'brouillon' | 'publie'
type EnLigne = Extract<Statut, 'publie' | 'supprime'>; // 'publie' La distribution est un comportement par défaut, pas un choix, et elle se retourne contre qui voulait tester l'union d'un bloc. Le type ci-dessous veut normaliser une valeur en tableau ; distribué, il produit une union de tableaux homogènes, et never y donne never au lieu d'un booléen.
// Normaliser une valeur qui arrive seule ou deja en tableau.
type EnListe<T> = T extends readonly unknown[] ? T : T[];
// Distribue sur l'union, le resultat est un tableau de chaines OU un tableau
// de nombres, jamais un tableau qui melange les deux.
type Ids = EnListe<string | number>; // string[] | number[]
// const ids: Ids = ['USB-64', 42];
// error TS2322: Type '(string | number)[]' is not assignable to type 'Ids'.
// Type '(string | number)[]' is not assignable to type 'string[]'.
// Meme mecanisme sur never : une union vide n'a aucun membre a evaluer, et le
// resultat n'est ni true ni false.
type EstNever<T> = T extends never ? true : false;
type N = EstNever<never>; // never Il suffit que T ne soit plus nu. L'envelopper des deux côtés dans un tuple à un élément ne change pas le sens du test, et coupe la distribution.
// Enveloppe dans un tuple a un element, T n'est plus nu : l'union est testee
// d'un bloc.
type EnListe<T> = [T] extends [readonly unknown[]] ? T : T[];
type Ids = EnListe<string | number>; // (string | number)[]
const ids: Ids = ['USB-64', 42];
type EstNever<T> = [T] extends [never] ? true : false;
type N = EstNever<never>; // true
type S = EstNever<string>; // falseinfer
infer ne s'emploie que dans la clause extends d'un type conditionnel. Il y déclare une variable de type que le compilateur remplit en confrontant T au motif, comme un filtrage par motif dans un switch C# : si la forme correspond, la variable est liée et utilisable dans la branche vraie ; sinon, c'est la branche fausse.
interface Produit {
reference: string;
prix: number;
}
// infer U declare une variable de type dans le motif teste. Si T a la forme
// Promise<quelque chose>, ce quelque chose est capture dans U.
type DeballeUnNiveau<T> = T extends Promise<infer U> ? U : T;
type A = DeballeUnNiveau<Promise<Produit>>; // Produit
type B = DeballeUnNiveau<string>; // string : pas de Promise, T revient tel quel
type C = DeballeUnNiveau<Promise<Promise<number>>>; // Promise<number>
// Un type conditionnel peut se rappeler lui-meme : il deballe tant qu'il reste
// une Promise, comme await le fait a l'execution.
type Deballe<T> = T extends Promise<infer U> ? Deballe<U> : T;
type D = Deballe<Promise<Promise<number>>>; // number
type E = Deballe<Promise<string> | number>; // string | number : T est nu, il distribue
// L'usage courant : nommer ce que rend une fonction sans le recopier.
async function chargerProduit(reference: string): Promise<Produit> {
return { reference, prix: 12 };
}
type Charge = Deballe<ReturnType<typeof chargerProduit>>; // Produit
// infer se pose partout ou un type peut apparaitre, et accepte une contrainte.
type Element<T> = T extends readonly (infer E)[] ? E : never;
type CodeHttp<T> = T extends `${infer N extends number}` ? N : never;
type F = Element<string[]>; // string
type G = CodeHttp<'404'>; // 404 : le nombre, et non plus la chaine Un seul niveau ne suffit pas pour une promesse de promesse, d'où la version récursive. La bibliothèque standard fournit la version complète, Awaited<T>, qui va un cran plus loin : elle ne cherche pas une Promise mais tout objet dont la méthode then prend une fonction, ce qui est exactement la règle qu'applique await. C'est elle qu'il faut employer ; l'écrire à la main sert à comprendre comment elle fonctionne. Enfin, infer N extends number pose une contrainte sur la variable : le motif n'est retenu que si elle est satisfaite, et dans un template literal la contrainte convertit '404' en 404.
Mapped types
Un mapped type est une boucle sur des clés : [K in keyof T] produit une propriété par clé, et l'expression à droite des deux-points en donne le type. C'est la construction qui fabrique un type à partir d'un autre, propriété par propriété, là où C# demanderait un générateur de code.
interface Client {
readonly id: number;
nom: string;
email?: string;
}
// [K in keyof T] parcourt les cles de T. A droite des deux-points, T[K] est le
// type d'origine de chaque propriete, transforme a volonte.
type Nullable<T> = { [K in keyof T]: T[K] | null };
type ClientNullable = Nullable<Client>;
// { readonly id: number | null; nom: string | null; email?: string | null | undefined }
// Mappe directement sur keyof T, le type est homomorphe : il recopie readonly
// et ? depuis l'original.
const saisie: ClientNullable = { id: null, nom: null };
// saisie.id = 3;
// error TS2540: Cannot assign to 'id' because it is a read-only property.
// Les modificateurs s'ajoutent par + (sous-entendu quand on l'omet) et se
// retirent par -.
type Modifiable<T> = { -readonly [K in keyof T]: T[K] };
type Complet<T> = { [K in keyof T]-?: T[K] };
type Brouillon<T> = { -readonly [K in keyof T]?: T[K] | null };
type M = Modifiable<Client>; // { id: number; nom: string; email?: string | undefined }
type R = Complet<Client>; // { readonly id: number; nom: string; email: string }
const brouillon: Brouillon<Client> = {};
brouillon.id = 7; // accepte : readonly a ete retire
// Sur un tuple, le mapped type transforme les elements et rend un tuple.
type Paire = Nullable<[number, string]>; // [number | null, string | null] Le détail qui compte est l'homomorphie. Quand la boucle porte directement sur keyof T, le compilateur sait d'où vient chaque clé et recopie ses modificateurs : un champ readonly le reste, un champ optionnel le reste, et un tableau ou un tuple reste un tableau ou un tuple. Une boucle sur une union de clés quelconque perd ce lien : les mêmes propriétés ressortent, mais toutes modifiables et obligatoires. Les préfixes -readonly et -? retirent un modificateur recopié, +readonly et +? — ou leur forme courte sans signe — l'ajoutent.
Template literal types
Un template literal type a la syntaxe d'un template literal, appliquée à des types : `on${Capitalize<K>}` est le type de la chaîne on suivie de K avec une majuscule. Capitalize, Uncapitalize, Uppercase et Lowercase sont intégrés au compilateur, pas écrits en TypeScript. Combinés à la clause as d'un mapped type, qui renomme une clé au passage, ils dérivent un jeu de noms d'un autre.
interface EvenementsPanier {
ajout: { reference: string; quantite: number };
retrait: { reference: string };
vidage: undefined;
}
// as renomme chaque cle au passage. keyof T & string ecarte les cles number et
// symbol, que Capitalize refuse : il n'opere que sur des chaines.
type Gestionnaires<T> = {
[K in keyof T & string as `on${Capitalize<K>}`]: (charge: T[K]) => void;
};
// Le type obtenu :
// { onAjout: (charge: { reference: string; quantite: number }) => void;
// onRetrait: (charge: { reference: string }) => void;
// onVidage: (charge: undefined) => void }
const gestionnaires: Gestionnaires<EvenementsPanier> = {
onAjout: (charge) => console.log(`+${charge.quantite} ${charge.reference}`),
onRetrait: (charge) => console.log(`-${charge.reference}`),
onVidage: () => console.log('panier vide'),
};
gestionnaires.onAjout({ reference: 'USB-64', quantite: 2 }); // +2 USB-64
// Un evenement ajoute a l'interface est un gestionnaire exige partout.
// const partiel: Gestionnaires<EvenementsPanier> = { onAjout() {}, onRetrait() {} };
// error TS2741: Property 'onVidage' is missing in type '{ onAjout(): void; onRetrait(): void; }' but required in type 'Gestionnaires<EvenementsPanier>'.
// Le chemin inverse : infer dans un template literal decoupe la chaine.
type NomEvenement<C> = C extends `on${infer N}` ? Uncapitalize<N> : never;
type Noms = NomEvenement<keyof typeof gestionnaires>; // 'ajout' | 'retrait' | 'vidage'
// Une union dans un template literal se developpe en produit cartesien.
type Coin = `${'haut' | 'bas'}-${'gauche' | 'droite'}`;
// 'haut-gauche' | 'haut-droite' | 'bas-gauche' | 'bas-droite' Le & string n'est pas décoratif. keyof T peut contenir des clés number et symbol, et Capitalize exige une chaîne : sans l'intersection, l'erreur TS2344 signale que K ne satisfait pas la contrainte string. L'intersection écarte ces clés au lieu de les convertir, et une clé numérique disparaît du résultat. Une clause as qui rend never supprime la clé de la même façon, ce qui sert à filtrer des propriétés selon leur type. Une union placée dans un template literal se développe en produit cartésien : deux unions de dix membres en font cent, et à partir de cent mille combinaisons le compilateur renonce, avec l'erreur TS2590.
Types utilitaires
Pick, Omit, Partial et les autres utilitaires de cette section ne sont pas des primitives du compilateur : ils sont déclarés en TypeScript ordinaire dans lib.es5.d.ts, en quelques lignes chacun, avec les outils des sections précédentes. Pick est un mapped type, Exclude un conditionnel distribué, et Omit combine les deux. Les réécrire montre ce qu'ils garantissent, et ce qu'ils ne garantissent pas.
interface Client {
readonly id: number;
nom: string;
email?: string;
motDePasse: string;
}
// Pick : un mapped type sur une partie des cles. K etant contraint a keyof T,
// P in K recopie readonly et ? comme un mapped type homomorphe.
type Choisir<T, K extends keyof T> = { [P in K]: T[P] };
// Omit : Pick sur les cles restantes, obtenues par un conditionnel distribue
// sur l'union keyof T.
type Exclure<T, U> = T extends U ? never : T;
type Retirer<T, K extends keyof T> = Choisir<T, Exclure<keyof T, K>>;
type Apercu = Choisir<Client, 'id' | 'nom'>; // { readonly id: number; nom: string }
type ClientPublic = Retirer<Client, 'motDePasse'>;
// { readonly id: number; nom: string; email?: string | undefined }
function publier(client: Client): ClientPublic {
const { motDePasse, ...reste } = client;
return reste;
}
console.log(publier({ id: 1, nom: 'Durand', motDePasse: 'secret' })); // { id: 1, nom: 'Durand' }
// Le Omit de la bibliotheque contraint K a keyof any, pas a keyof T : une
// faute de frappe ne retire rien, et ne le signale pas.
type Fuite = Omit<Client, 'motDePase'>; // motDePasse est toujours la
// type Refus = Retirer<Client, 'motDePase'>;
// error TS2344: Type '"motDePase"' does not satisfy the constraint 'keyof Client'. La différence entre Retirer et Omit est volontaire côté TypeScript : contraindre K à keyof any permet d'écrire des types génériques qui retirent une clé peut-être absente. Le prix est qu'une faute de frappe dans le nom de la clé laisse passer le mot de passe sans un avertissement. Pour un type exposé à l'extérieur, une version contrainte à keyof T est plus sûre.
Omit a un second angle mort : il n'est pas distributif. Sur une union, keyof ne rend que les clés communes à tous les membres, si bien que Omit d'une union discriminée produit un seul objet sans les champs propres à chaque variante. La version qui préserve l'union se distribue explicitement : T extends unknown ? Omit<T, K> : never.
// Les definitions de lib.es5.d.ts, renommees pour ne pas masquer les originales.
type Partiel<T> = { [P in keyof T]?: T[P] };
type Requis<T> = { [P in keyof T]-?: T[P] };
type Dictionnaire<K extends keyof any, T> = { [P in K]: T };
type Retour<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
interface Options {
delaiMs?: number;
tentatives?: number;
}
const defauts: Requis<Options> = { delaiMs: 500, tentatives: 3 };
function configurer(options: Partiel<Options>): Requis<Options> {
return { ...defauts, ...options };
}
console.log(configurer({ tentatives: 5 })); // { delaiMs: 500, tentatives: 5 }
// Mappe sur une union de litteraux, Record exige chacune des cles.
type Langue = 'fr' | 'en' | 'de';
const libelles: Dictionnaire<Langue, string> = { fr: 'Panier', en: 'Cart', de: 'Warenkorb' };
type Config = Retour<typeof configurer>; // Requis<Options> Le any de ReturnType n'est pas une paresse. Sous strictFunctionTypes, les paramètres se comparent en sens inverse : une fonction qui attend une string n'est pas assignable à une fonction qui accepterait n'importe quel unknown, et la contrainte (...args: unknown[]) => unknown refuserait toute fonction dont les paramètres sont typés. any, ou never en position de paramètre, les accepte toutes.
Unions discriminées
Le cas le plus courant de modélisation est une valeur qui peut prendre plusieurs formes : un paiement par carte, par virement ou en espèces. Le premier réflexe, un seul type où tout champ particulier devient optionnel, perd l'information essentielle : quel champ est présent dans quel cas.
// Un seul type pour trois moyens de paiement : chaque champ propre a l'un
// d'eux devient optionnel, et le compilateur ne sait plus lequel est la.
interface Paiement {
type: 'carte' | 'virement' | 'especes';
numeroMasque?: string;
iban?: string;
rendu?: number;
}
function libelle(p: Paiement): string {
switch (p.type) {
case 'carte':
return `Carte ${p.numeroMasque}`; // 'Carte undefined' si le champ manque
case 'virement':
return `Virement depuis ${p.iban}`;
case 'especes':
// return `Especes, rendu ${p.rendu.toFixed(2)} EUR`;
// error TS18048: 'p.rendu' is possibly 'undefined'.
return `Especes, rendu ${p.rendu?.toFixed(2)} EUR`;
}
}
// Et rien n'interdit une combinaison absurde.
const incoherent: Paiement = { type: 'especes', iban: 'FR76 3000 6000 0112 3456 7890 189' };
console.log(libelle(incoherent)); // Especes, rendu undefined EURLa forme juste est une union de variantes qui partagent une propriété de type littéral, le discriminant. Tester ce discriminant restreint la valeur à une seule variante, et ses champs deviennent obligatoires et accessibles sans point d'interrogation. C'est l'équivalent d'une hiérarchie de records scellée en C#, sans classe ni héritage : des objets simples, qui passent tels quels dans un JSON.
// Une variante par moyen de paiement, chacune avec ses seuls champs, toutes
// marquees par un litteral sous la meme cle : c'est le discriminant.
type Paiement =
| { type: 'carte'; numeroMasque: string }
| { type: 'virement'; iban: string }
| { type: 'especes'; rendu: number };
function libelle(p: Paiement): string {
switch (p.type) {
case 'carte':
// Le test sur p.type a restreint p a la seule variante carte.
return `Carte ${p.numeroMasque}`;
case 'virement':
return `Virement depuis ${p.iban}`;
case 'especes':
return `Especes, rendu ${p.rendu.toFixed(2)} EUR`;
}
// Pas de return final, et pas d'erreur : les trois cas couvrent l'union, et
// le compilateur sait que cette ligne est inatteignable.
}
console.log(libelle({ type: 'especes', rendu: 3.5 })); // Especes, rendu 3.50 EUR
// const incoherent: Paiement = { type: 'especes', iban: 'FR76 3000 6000 0112 3456 7890 189' };
// error TS2353: Object literal may only specify known properties, and 'iban' does not exist in type '{ type: "especes"; rendu: number; }'. Le compilateur sait déjà qu'un switch couvre toute l'union, puisqu'il n'exige pas de return final. Mais il ne protège qu'à moitié le jour où une variante s'ajoute : une fonction qui rend une valeur et dont le type de retour est déclaré échoue avec TS2366, « Function lacks ending return statement », un message qui ne nomme pas la variante oubliée ; une fonction qui ne rend rien compile sans un mot. La branche default qui passe la valeur à un paramètre de type never ferme cette brèche.
type Paiement =
| { type: 'carte'; numeroMasque: string }
| { type: 'virement'; iban: string }
| { type: 'especes'; rendu: number };
// La variante qui arrivera plus tard. L'ajouter a l'union ci-dessus, sans
// toucher a frais, fait echouer la compilation dans le default :
// | { type: 'cheque'; numero: string }
// Seul never est assignable a never : cet appel ne compile que si le
// compilateur a prouve que la valeur ne peut pas exister.
function nonTraite(valeur: never): never {
throw new Error(`Variante non traitee : ${JSON.stringify(valeur)}`);
}
function frais(p: Paiement): number {
switch (p.type) {
case 'carte':
return 0.25;
case 'virement':
case 'especes':
return 0;
default:
// Chaque case a retire une variante ; il n'en reste aucune, p est de type
// never. Avec la variante cheque, il en reste une, et l'erreur tombe ici :
// error TS2345: Argument of type '{ type: "cheque"; numero: string; }' is not assignable to parameter of type 'never'.
return nonTraite(p);
}
}
console.log(frais({ type: 'carte', numeroMasque: '**** 4242' })); // 0.25
// Le default sert aussi a l'execution : une valeur venue d'un JSON n'a pas lu
// les types, et une variante inconnue leve au lieu de passer en silence. Le raisonnement tient en une ligne : chaque case retire une variante du type de p, et dans le default il n'en reste aucune, donc p est de type never, le seul qui soit assignable à never. Qu'une variante soit ajoutée, elle survit jusqu'au default, et l'erreur la nomme en toutes lettres, à l'endroit exact où elle n'est pas traitée — dans chaque switch du projet qui porte ce garde. Une variable locale fait le même travail, const controle: never = p, avec l'erreur TS2322, tout comme p satisfies never avec TS1360 ; la fonction a l'avantage de lever à l'exécution quand une donnée externe n'a pas respecté les types.