Types conditionnels#
Les types conditionnels constituent l’un des mécanismes les plus puissants et les plus expressifs du système de types de TypeScript. Introduits en TypeScript 2.8, ils permettent de définir des types dont la valeur dépend d’une condition portant sur d’autres types. En combinaison avec infer et la récursivité, ils rendent possible l’implémentation de tous les types utilitaires de la bibliothèque standard, et bien plus encore.
Syntaxe de base#
Définition 25 (Type conditionnel)
Un type conditionnel s’écrit T extends U ? X : Y. Il évalue à X si le type T est assignable à U, et à Y sinon. La syntaxe est intentionnellement semblable à l’opérateur ternaire de JavaScript, mais elle opère entièrement au niveau des types, sans aucun calcul à l’exécution.
La forme la plus simple est un prédicat de type :
// Est-ce que T est une chaîne ?
type EstChaîne<T> = T extends string ? true : false;
type R1 = EstChaîne<string>; // true
type R2 = EstChaîne<number>; // false
type R3 = EstChaîne<"bonjour">; // true — les types littéraux sont des sous-types
type R4 = EstChaîne<string | number>; // boolean — distribution (voir section 2)
// Est-ce que T est un tableau ?
type EstTableau<T> = T extends any[] ? true : false;
type R5 = EstTableau<number[]>; // true
type R6 = EstTableau<string>; // false
type R7 = EstTableau<readonly string[]>; // false — readonly T[] n'étend pas T[]
// Est-ce que T est une fonction ?
type EstFonction<T> = T extends (...args: any[]) => any ? true : false;
type R8 = EstFonction<() => void>; // true
type R9 = EstFonction<(x: number) => string>; // true
type R10 = EstFonction<string>; // false
Exemple 7 (Aplatir un type conditionnel)
Les types conditionnels peuvent être imbriqués, comme des ternaires enchaînés :
type TypePrimitif<T> =
T extends string ? "chaîne" :
T extends number ? "nombre" :
T extends boolean ? "booléen" :
T extends null ? "null" :
T extends undefined ? "undefined" :
"objet";
type T1 = TypePrimitif<string>; // "chaîne"
type T2 = TypePrimitif<42>; // "nombre" — 42 est un sous-type de number
type T3 = TypePrimitif<Date>; // "objet"
Distribution sur les unions#
La propriété la plus déroutante — et la plus puissante — des types conditionnels est leur comportement face aux unions nues.
Définition 26 (Distribution sur les unions)
Lorsqu’un type conditionnel T extends U ? X : Y est évalué avec un type union T = A | B | C, et que T est un paramètre de type nu (non enveloppé), le type conditionnel se distribue sur chaque membre de l’union séparément :
(A | B | C) extends U ? X : Y devient (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)
Le résultat est l’union des résultats de chaque évaluation.
type EstChaîne<T> = T extends string ? true : false;
// Distribution sur l'union string | number :
type R = EstChaîne<string | number>;
// = (string extends string ? true : false) | (number extends string ? true : false)
// = true | false
// = boolean
// Exemple pratique : filtrer les types d'une union
type FiltrerChaînes<T> = T extends string ? T : never;
type Mélange = string | number | boolean | "alpha" | "beta" | 42;
type SeulementChaînes = FiltrerChaînes<Mélange>;
// = (string extends string ? string : never)
// | (number extends string ? number : never)
// | (boolean extends string ? boolean : never)
// | ("alpha" extends string ? "alpha" : never)
// | ("beta" extends string ? "beta" : never)
// | (42 extends string ? 42 : never)
// = string | never | never | "alpha" | "beta" | never
// = string | "alpha" | "beta"
Remarque 17
Le type never est l’élément neutre de l’union : T | never = T. C’est pourquoi les filtres de types utilisent never dans la branche « fausse » : les membres qui ne satisfont pas la condition disparaissent silencieusement de l’union résultante. C’est le même mécanisme qu’utilisent Exclude<T, U> et Extract<T, U> en interne.
Pour désactiver la distribution, il suffit d’envelopper le paramètre de type dans un tuple à un élément :
// Avec distribution — se distribue sur les membres
type EstChaîneDistrib<T> = T extends string ? true : false;
type R1 = EstChaîneDistrib<string | number>; // boolean
// Sans distribution — évalue l'union entière contre string
type EstChaîneSansDistrib<T> = [T] extends [string] ? true : false;
type R2 = EstChaîneSansDistrib<string | number>; // false — l'union entière n'est pas string
type R3 = EstChaîneSansDistrib<string>; // true
Cette technique est essentielle lorsqu’on veut tester si un type est exactement never :
// Sans [T] extends [never], la distribution sur never donne never (zéro membres)
type EstNever<T> = [T] extends [never] ? true : false;
type E1 = EstNever<never>; // true
type E2 = EstNever<string>; // false
type E3 = EstNever<string | never>; // false (= EstNever<string>)
infer — inférence dans les types conditionnels#
Le mot-clé infer permet d’introduire une variable de type locale dans la branche conditionnelle, que le compilateur va résoudre en cherchant quel type doit aller à cet endroit pour que la condition soit satisfaite.
Définition 27 (Le mot-clé infer)
infer X introduit une variable X dans la branche extends d’un type conditionnel. Si la condition est satisfaite, X est lié au type extrait de la position où infer apparaît dans le patron (pattern). X n’est disponible que dans la branche vraie (? X ...), pas dans la branche fausse.
Extraction du type d’élément d’un tableau#
type ÉlémentTableau<T> = T extends (infer E)[] ? E : never;
type E1 = ÉlémentTableau<string[]>; // string
type E2 = ÉlémentTableau<number[]>; // number
type E3 = ÉlémentTableau<(string | null)[]>; // string | null
type E4 = ÉlémentTableau<Date>; // never — Date n'est pas un tableau
Extraction du type de retour et des paramètres#
type TypeRetour<T> = T extends (...args: any[]) => infer R ? R : never;
type R1 = TypeRetour<() => string>; // string
type R2 = TypeRetour<(x: number) => boolean>; // boolean
type R3 = TypeRetour<string>; // never
type TypesParamètres<T> = T extends (...args: infer P) => any ? P : never;
type P1 = TypesParamètres<(a: number, b: string) => void>; // [number, string]
type P2 = TypesParamètres<() => number>; // []
infer dans plusieurs positions#
On peut utiliser infer plusieurs fois dans un même patron :
// Extraire le premier et le dernier type d'un tuple
type Premier<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type Dernier<T extends any[]> = T extends [...any[], infer L] ? L : never;
type P = Premier<[string, number, boolean]>; // string
type D = Dernier<[string, number, boolean]>; // boolean
// Extraire la valeur d'une Promise imbriquée
type DépromessifierUn<T> = T extends Promise<infer V> ? V : T;
type Dépromessifier<T> = T extends Promise<infer V> ? Dépromessifier<V> : T;
type V1 = DépromessifierUn<Promise<string>>; // string
type V2 = DépromessifierUn<Promise<Promise<number>>>; // Promise<number>
type V3 = Dépromessifier<Promise<Promise<number>>>; // number (récursif)
Remarque 18
Lorsqu”infer apparaît dans des positions contravariantes (typiquement les paramètres de fonctions), TypeScript intersecte les types inférés au lieu de les unifier. Cela peut produire des résultats inattendus avec les surcharges. Dans les positions covariantes (valeurs de retour, éléments de tableau), TypeScript unit les types inférés.
Récursivité#
Les types conditionnels peuvent se référencer eux-mêmes, permettant des transformations qui parcourent des structures arbitrairement imbriquées.
Définition 28 (Type récursif)
Un type récursif est un alias de type qui s’utilise lui-même dans sa définition. TypeScript tolère un niveau raisonnable de récursivité (généralement jusqu’à une profondeur de 1000), au-delà duquel il signale une erreur de profondeur de récursion maximale dépassée. Les types récursifs permettent de modéliser des structures arborescentes, des tuples de profondeur variable, et des transformations en profondeur.
DeepReadonly<T>#
type DeepReadonly<T> =
T extends (infer E)[]
? ReadonlyArray<DeepReadonly<E>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Arbre {
valeur: number;
gauche: Arbre | null;
droite: Arbre | null;
métadonnées: { auteur: string; date: Date };
}
type ArbreImmuable = DeepReadonly<Arbre>;
// Toutes les propriétés, à tous les niveaux, deviennent readonly
Flatten<T>#
// Aplatir un tableau d'un niveau
type Flatten<T> = T extends Array<infer E> ? E : T;
type F1 = Flatten<string[]>; // string
type F2 = Flatten<number[][]>; // number[] — un seul niveau
type F3 = Flatten<string>; // string — pas de tableau, identité
// Version profonde (récursive)
type FlattenProfond<T> =
T extends Array<infer E>
? FlattenProfond<E>
: T;
type F4 = FlattenProfond<number[][][]>; // number
Remarque 19
La récursivité dans les types conditionnels a des limites. TypeScript limite la profondeur d’évaluation pour éviter les boucles infinies. Si un type récursif ne converge pas (par exemple si la transformation n’élimine jamais le cas récursif), le compilateur signalera l’erreur Type instantiation is excessively deep and possibly infinite. Il convient de s’assurer que chaque branche récursive traite un type strictement plus simple que le type d’entrée.
Types conditionnels dans la bibliothèque standard#
Les types utilitaires présentés au chapitre précédent sont implémentés en utilisant exactement les mécanismes décrits ici. Étudier leurs définitions est un excellent moyen de comprendre ces mécanismes en profondeur.
// NonNullable<T> — implémentation dans lib.es5.d.ts
type NonNullable<T> = T & {};
// Explication : T & {} exclut null et undefined car ces types
// ne sont pas assignables à {} (le type de tous les objets non-nuls).
// ReturnType<T> — avec infer
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;
// Parameters<T> — avec infer
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
// ConstructorParameters<T> — avec infer sur le constructeur
type ConstructorParameters<T extends abstract new (...args: any) => any> =
T extends abstract new (...args: infer P) => any ? P : never;
// InstanceType<T> — infer sur le type de retour du constructeur
type InstanceType<T extends abstract new (...args: any) => any> =
T extends abstract new (...args: any) => infer R ? R : any;
// Awaited<T> — récursif pour les Promises imbriquées
type Awaited<T> =
T extends null | undefined ? T :
T extends object & { then(onfulfilled: infer F, ...args: infer _): any }
? F extends (value: infer V, ...args: infer _) => any
? Awaited<V>
: never
: T;
Exemple 8 (Dériver les types de retour d’un objet de services)
interface Services {
obtenirUtilisateur: (id: number) => Promise<Utilisateur>;
listerArticles: () => Promise<Article[]>;
supprimer: (id: number) => void;
}
// Extraire les types de retour de chaque méthode
type RetourServices = {
[K in keyof Services]: Awaited<ReturnType<Services[K]>>;
};
// {
// obtenirUtilisateur: Utilisateur;
// listerArticles: Article[];
// supprimer: void;
// }
Bonnes pratiques et pièges#
Favoriser les types utilitaires existants#
La première règle est d’utiliser ce qui existe déjà avant de construire ses propres types conditionnels. TypeScript fournit une bibliothèque riche ; Partial, Required, Pick, Omit, ReturnType, Awaited et leurs consorts couvrent la grande majorité des besoins courants.
Remarque 20
Un type conditionnel complexe peut être difficile à lire, à déboguer et à faire évoluer. Avant d’en écrire un, il convient de se poser trois questions : (1) Existe-t-il un type utilitaire standard qui fait déjà ce travail ? (2) Est-ce qu’une combinaison de types utilitaires existants permet d’atteindre le même résultat ? (3) La complexité supplémentaire apporte-t-elle une valeur suffisante pour justifier le coût de maintenance ? Si les réponses sont toutes négatives, alors le type conditionnel personnalisé est justifié.
Déboguer avec des assignations intermédiaires#
Lorsqu’un type conditionnel ne se comporte pas comme prévu, il est utile de l’évaluer en plusieurs étapes en créant des types intermédiaires nommés :
// Au lieu de tout inline
type Complexe<T> = T extends object
? { [K in keyof T]: T[K] extends string ? Uppercase<T[K]> : T[K] }
: T;
// Décomposer pour déboguer
type PropriétésMajuscules<T> = {
[K in keyof T]: T[K] extends string ? Uppercase<T[K]> : T[K];
};
type Complexe2<T> = T extends object ? PropriétésMajuscules<T> : T;
// Tester chaque étape séparément
type Étape1 = PropriétésMajuscules<{ nom: string; âge: number }>;
// { nom: Uppercase<string>; âge: number }
// = { nom: string; âge: number } — Uppercase<string> = string
Limites de profondeur de récursion#
Les types récursifs doivent avoir une condition d’arrêt claire et converger rapidement. Pour les structures à grande profondeur, il peut être nécessaire d’ajouter une limite explicite via un compteur de récursion :
// Tuple de N éléments de type T — avec compteur de récursion
type TupleN<T, N extends number, Acc extends T[] = []> =
Acc["length"] extends N
? Acc
: TupleN<T, N, [...Acc, T]>;
type Triplet = TupleN<string, 3>; // [string, string, string]
type Quintuplet = TupleN<number, 5>; // [number, number, number, number, number]
Préférer la clarté à la brièveté#
Les types conditionnels imbriqués sur plus de deux niveaux deviennent rapidement impossibles à lire. Il vaut mieux décomposer en plusieurs types nommés qu’écrire une expression monolithique de dix lignes.
Visualisation : distribution sur une union#
Résumé#
Ce chapitre a présenté les types conditionnels, mécanisme fondamental du système de types avancé de TypeScript :
La syntaxe de base
T extends U ? X : Yévalue un type en fonction d’une condition d’assignabilité. Elle peut être imbriquée pour exprimer des décisions à plusieurs branches.La distribution sur les unions est le comportement par défaut lorsque
Test un paramètre de type nu : le type conditionnel est évalué séparément pour chaque membre de l’union, et les résultats sont réunis en une nouvelle union.[T] extends [U]désactive ce comportement.inferpermet d”extraire des sous-types à partir de la structure d’un type composé : type d’élément d’un tableau, type de retour d’une fonction, paramètres, premier ou dernier élément d’un tuple. Il n’est disponible que dans la branche vraie du type conditionnel.La récursivité permet d’implémenter
DeepReadonly<T>,FlattenProfond<T>,Dépromessifier<T>et d’autres transformations qui parcourent des structures imbriquées. Elle doit toujours converger et respecter les limites de profondeur du compilateur.La bibliothèque standard utilise ces mécanismes pour implémenter
ReturnType,Parameters,ConstructorParameters,InstanceTypeetAwaited. Lire ces définitions est un excellent exercice de compréhension.Les bonnes pratiques recommandent de préférer les types utilitaires existants, de décomposer les types complexes en étapes nommées, et de garder à l’esprit que la lisibilité a plus de valeur à long terme que la brièveté.
Dans le chapitre suivant, nous aborderons les types mappés, qui complètent les types conditionnels en permettant de transformer chaque propriété d’un type objet de façon uniforme, et avec lesquels on peut également filtrer, renommer ou remanier les clés d’un type.