Maîtrisez les conditional types TypeScript : syntaxe T extends U, distributivité sur les unions, mot-clé infer, variadic tuples, utility types natifs et création de DeepPartial, Flatten, UnpackPromise.
Conditional types : syntaxe et comportement
Les conditional types permettent d'exprimer des types qui dependent d'une condition. La syntaxe ressemble a un ternaire JavaScript, mais elle s'applique entierement au systeme de types de TypeScript, sans aucune execution a runtime.
La forme generale est la suivante :
// Forme generale : T extends U ? X : Y
// Si T est assignable a U, le type resultant est X, sinon Y
type EstString<T> = T extends string ? true : false;
// Exemples d'utilisation
type A = EstString<string>; // true
type B = EstString<number>; // false
type C = EstString<"hello">; // true (les literals etendent string)
La condition T extends U ne signifie pas "T herite de U" au sens POO. Elle signifie que T est assignable a U, c'est-a-dire que T est un sous-type de U dans le systeme structurel de TypeScript.
// Exemple plus pratique : typer conditionnellement un retour
// Si T est un tableau, on retourne le type des elements, sinon T lui-meme
type Unwrap<T> = T extends Array<infer Item> ? Item : T;
type UnwrappedString = Unwrap<string[]>; // string
type UnwrappedNumber = Unwrap<number[]>; // number
type UnwrappedBoolean = Unwrap<boolean>; // boolean (pas un tableau)
Conditional types deferres
Quand TypeScript ne connait pas encore la valeur concrete de T (car T est un parametre generique non resolu), le type conditionnel reste "differe" : TypeScript le resoudra uniquement quand T sera instancie avec un type concret.
// Ce type reste differe tant que T n'est pas connu
function extraireValeur<T>(val: T): T extends string ? number : T {
// TypeScript ne peut pas verifier le corps ici sans assertion
// car T est encore generique (non resolu)
return val as any;
}
// Une fois instancie, le type est resolu
const longueur = extraireValeur("hello"); // number
const identite = extraireValeur(42); // 42 (number literal)
Distributivite sur les unions
C'est l'une des proprietes les plus importantes — et les plus surprenantes — des conditional types. Quand T est un type union et qu'on l'utilise directement dans un conditional type, TypeScript distribue automatiquement la condition sur chaque membre de l'union.
// TypeScript distribue automatiquement sur l'union
type NonNullable<T> = T extends null | undefined ? never : T;
// Avec une union : string | null | undefined
// TypeScript evalue chaque membre separement :
// string extends null | undefined ? never : string → string
// null extends null | undefined ? never : null → never
// undefined extends null | undefined ? never : undefined → never
// Resultat final : string | never | never = string
type Nettoyee = NonNullable<string | null | undefined>; // string
// Distributivite ACTIVE : T est un parametre nu
type DistribueActif<T> = T extends string ? "oui" : "non";
type R1 = DistribueActif<string | number>; // "oui" | "non"
// Distributivite DESACTIVEE : T est enveloppe dans [T]
type DistribueDesactive<T> = [T] extends [string] ? "oui" : "non";
type R2 = DistribueDesactive<string | number>; // "non"
// car [string | number] n'est pas assignable a [string]
// Cas utile : desactiver la distribution pour comparer l'union entiere
type EstExactementString<T> = [T] extends [string] ? true : false;
type R3 = EstExactementString<string>; // true
type R4 = EstExactementString<string | number>; // false
never dans les unions et la distributivite
Le type never est le type vide de TypeScript. Dans une union, never disparait. Ce comportement est central dans les conditional types distributifs.
// never dans une union est absorbe (il disparait)
type T1 = string | never; // string
type T2 = number | never; // number
// Principe utilise pour filtrer les unions
// ExtraireStrings distribue sur chaque membre et elimine les non-strings
type ExtraireStrings<T> = T extends string ? T : never;
type Resultat = ExtraireStrings<string | number | boolean | "hello">;
// string extends string → string ✓
// number extends string → never (filtre)
// boolean extends string → never (filtre)
// "hello" extends string → "hello" ✓
// Resultat : string | "hello" (simplifie en string)
Le mot-cle infer : extraire des types depuis des patterns
Le mot-cle infer est utilise exclusivement dans la branche extends d'un conditional type. Il permet de "capturer" une partie du type inspecte et de la reutiliser dans les branches du conditionnel. C'est la fonctionnalite la plus puissante des conditional types.
// Syntaxe de base : infer dans un conditional type
// "Si T correspond au pattern, capture la partie infer R"
type ExtraireRetour<T> = T extends (...args: any[]) => infer R ? R : never;
// Exemples
type RetourSalutation = ExtraireRetour<() => string>; // string
type RetourCalcul = ExtraireRetour<() => number>; // number
type RetourAsynchrone = ExtraireRetour<() => Promise<boolean>>; // Promise<boolean>
type PasUneFonction = ExtraireRetour<string>; // never
infer R fonctionne comme une variable de type locale : TypeScript deduit automatiquement ce que R doit etre pour que le pattern corresponde a T. Si le pattern ne correspond pas, la branche never est choisie.
infer ne peut apparaitre que dans la clause extends d'un conditional type. Il est impossible de l'utiliser ailleurs (pas dans les generiques normaux, pas dans les interfaces).
// infer peut capturer plusieurs positions dans le meme pattern
// Ici on capture separement le type des arguments et le type de retour
type DecomposerFonction<T> =
T extends (...args: infer Args) => infer Retour
? { args: Args; retour: Retour }
: never;
// Resultat : { args: [string, number]; retour: boolean }
type Info = DecomposerFonction<(nom: string, age: number) => boolean>;
type InfoArgs = Info['args']; // [string, number]
type InfoRetour = Info['retour']; // boolean
infer en pratique : cas concrets
Voici les patterns d'infer les plus utiles au quotidien, avec des exemples concrets et expliques.
Extraire le type de retour d'une fonction
// Equivalent maison de ReturnType<T> integre dans TypeScript
type MonReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;
// Cas d'usage : typer une variable a partir du retour d'une fonction
function creerUtilisateur() {
return { id: 1, nom: "Alice", role: "admin" as const };
}
// On capture automatiquement le type de retour sans le re-declarer
type Utilisateur = MonReturnType<typeof creerUtilisateur>;
// { id: number; nom: string; role: "admin" }
// Utile dans les tests ou les composants Angular
// pour eviter de dupliquer les interfaces
Extraire le type des elements d'un tableau
// Extraire le type T depuis T[] ou ReadonlyArray<T>
type ElementTableau<T> = T extends ReadonlyArray<infer Item> ? Item : never;
// Exemples
type ItemString = ElementTableau<string[]>; // string
type ItemObjet = ElementTableau<{ id: number }[]>; // { id: number }
type ItemLiteral = ElementTableau<readonly [1, 2, 3]>; // 1 | 2 | 3
// Application concrete : typer le callback d'un tableau de facon dynamique
function traiterItems<T extends any[]>(
tableau: T,
callback: (item: ElementTableau<T>) => void
): void {
tableau.forEach(callback);
}
Extraire le type du premier argument
// Capturer uniquement le premier argument d'une fonction
// Le reste (..._reste) est ignore mais doit etre declare
type PremierArgument<T> =
T extends (premier: infer P, ...reste: any[]) => any ? P : never;
// Exemples
type PremierDeLog = PremierArgument<typeof console.log>; // any
type PremierDeSetItem = PremierArgument<typeof localStorage.setItem>; // string
// Cas utile : valider le type attendu par un handler Angular
function onFormSubmit(event: Event, data: { nom: string }): void {}
type PremierArgSubmit = PremierArgument<typeof onFormSubmit>; // Event
Extraire le type d'une Promise
// Deballer le type T depuis Promise<T>
type UnpackPromise<T> = T extends Promise<infer Valeur> ? Valeur : T;
// Exemples
type ValeurString = UnpackPromise<Promise<string>>; // string
type ValeurObjet = UnpackPromise<Promise<{ id: number }>>; // { id: number }
type DejaResolu = UnpackPromise<boolean>; // boolean (pas une Promise)
// Version recursive pour les Promises imbriquees (comme Awaited integre)
type DeepUnpack<T> = T extends Promise<infer V> ? DeepUnpack<V> : T;
type ValeurProfonde = DeepUnpack<Promise<Promise<Promise<string>>>>; // string
infer avec les variadic tuples
Les variadic tuples (TypeScript 4.0+) permettent d'exprimer des tuples de longueur variable avec ...T. Combines avec infer, ils ouvrent des possibilites de manipulation de types tres avancees.
// Extraire le premier element d'un tuple
type PremierElement<T extends any[]> =
T extends [infer Premier, ...any[]] ? Premier : never;
type P1 = PremierElement<[string, number, boolean]>; // string
type P2 = PremierElement<[42, "hello"]>; // 42 (literal)
type P3 = PremierElement<[]>; // never
// Extraire le dernier element d'un tuple
type DernierElement<T extends any[]> =
T extends [...any[], infer Dernier] ? Dernier : never;
type D1 = DernierElement<[string, number, boolean]>; // boolean
type D2 = DernierElement<["a", "b", "c"]>; // "c"
// Extraire la "queue" d'un tuple (tout sauf le premier element)
type Queue<T extends any[]> =
T extends [any, ...infer Reste] ? Reste : never;
type Q1 = Queue<[string, number, boolean]>; // [number, boolean]
type Q2 = Queue<[string]>; // []
type Q3 = Queue<[]>; // never
// Application avancee : type d'une fonction qui accepte les memes args
// qu'une autre fonction, mais avec un argument supplementaire en debut
type AjouterArgContexte<T extends (...args: any) => any> =
T extends (...args: infer Args) => infer Retour
? (contexte: string, ...args: Args) => Retour
: never;
// Fonction originale : (id: number, options: Options) => User
// Type genere : (contexte: string, id: number, options: Options) => User
type AvecContexte = AjouterArgContexte<(id: number, opts: { cache: boolean }) => string>;
// (contexte: string, id: number, opts: { cache: boolean }) => string
infer est particulierement utile pour typer des systemes de middlewares, des pipelines de fonctions ou des decorateurs TypeScript avances.
Utility types natifs bases sur infer
TypeScript fournit plusieurs utility types standards qui utilisent infer en interne. Comprendre leur implementation permet de mieux les utiliser et de creer ses propres variantes.
| Utility type | Utilisation | Implementation interne |
|---|---|---|
ReturnType<T> |
Type de retour d'une fonction | T extends (...args: any) => infer R ? R : any |
Parameters<T> |
Tuple des arguments d'une fonction | T extends (...args: infer P) => any ? P : never |
InstanceType<T> |
Type d'instance d'un constructeur | T extends new (...args: any) => infer R ? R : any |
Awaited<T> |
Deballe recursivement les Promises | Utilise infer + recursion |
ConstructorParameters<T> |
Tuple des args du constructeur | T extends new (...args: infer P) => any ? P : never |
// ReturnType : capturer le retour d'une fonction
function fetchUtilisateur(id: number): Promise<{ id: number; nom: string }> {
return fetch(`/api/users/${id}`).then(r => r.json());
}
// Au lieu de reecrire le type a la main, on le deduit automatiquement
type ReponseUtilisateur = Awaited<ReturnType<typeof fetchUtilisateur>>;
// { id: number; nom: string }
// Si fetchUtilisateur change, ReponseUtilisateur se met a jour seul
// Parameters : recuperer le type des arguments
function creerComposant(nom: string, config: { standalone: boolean; selector: string }): void {}
type ArgsCreerComposant = Parameters<typeof creerComposant>;
// [nom: string, config: { standalone: boolean; selector: string }]
// Utile pour re-appeler une fonction avec les memes arguments
function wrapper(...args: Parameters<typeof creerComposant>): void {
console.log("Avant creation...");
creerComposant(...args); // TypeScript verifie les types automatiquement
console.log("Apres creation.");
}
// InstanceType : obtenir le type d'instance depuis un constructeur
class ServiceAuthentification {
utilisateur: string | null = null;
connecter(nom: string): void {
this.utilisateur = nom;
}
}
// Recuperer le type de l'instance sans instantier la classe
type InstanceAuth = InstanceType<typeof ServiceAuthentification>;
// ServiceAuthentification (equivalent, mais utile avec des factories)
// Usage courant : fabrique generique
function creerService<T extends new (...args: any) => any>(
Classe: T,
...args: ConstructorParameters<T>
): InstanceType<T> {
return new Classe(...args); // TypeScript type le retour automatiquement
}
Creer ses propres utility types avec infer
La vraie puissance des conditional types et d'infer se revele quand on construit ses propres utility types metier. Voici trois exemples complets et realistes.
Flatten : aplatir les tableaux imbriques
// Flatten<T> : si T est un tableau, retourne le type des elements,
// sinon retourne T tel quel
type Flatten<T> = T extends Array<infer Item> ? Item : T;
// Exemples
type F1 = Flatten<string[]>; // string
type F2 = Flatten<number[][]>; // number[] (un seul niveau)
type F3 = Flatten<boolean>; // boolean (pas un tableau)
type F4 = Flatten<Array<1 | 2 | 3>>; // 1 | 2 | 3 (union des elements)
// Version recursive pour aplatir completement les tableaux imbriques
type FlattenDeep<T> = T extends Array<infer Item> ? FlattenDeep<Item> : T;
type FD1 = FlattenDeep<string[][][]>; // string (completement aplati)
DeepReadonly : rendre un objet immutable en profondeur
// DeepReadonly<T> : rend toutes les proprietes readonly recursivement
// Cas d'usage : typer des configurations immuables dans Angular
type DeepReadonly<T> = T extends (infer Item)[]
? ReadonlyArray<DeepReadonly<Item>> // Si tableau, parcourir recursivement
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> } // Si objet, rendre chaque prop readonly
: T; // Sinon type primitif, retourner tel quel
// Exemple concret : configuration Angular immuable
interface ConfigApp {
api: {
baseUrl: string;
timeout: number;
};
features: string[];
}
type ConfigImmuable = DeepReadonly<ConfigApp>;
// {
// readonly api: {
// readonly baseUrl: string;
// readonly timeout: number;
// };
// readonly features: ReadonlyArray<string>;
// }
const config: ConfigImmuable = {
api: { baseUrl: "https://api.example.com", timeout: 5000 },
features: ["dark-mode", "notifications"]
};
// config.api.baseUrl = "autre"; // ERREUR TypeScript — readonly !
ExtractRouteParams : extraire les params d'une route Angular
// Utilitaire pour extraire les parametres de route depuis une chaine
// Ex: "/users/:id/posts/:postId" → { id: string; postId: string }
// Etape 1 : extraire les noms des params (commencant par :)
type ExtraireParamNoms<Route extends string> =
Route extends `${string}:${infer Param}/${infer Reste}`
? Param | ExtraireParamNoms<Reste> // Recursion sur la suite
: Route extends `${string}:${infer Param}`
? Param // Dernier parametre
: never; // Pas de parametre
// Etape 2 : construire un objet depuis l'union de noms
type RouteParams<Route extends string> = {
[K in ExtraireParamNoms<Route>]: string
};
// Exemples
type ParamsUser = RouteParams<"/users/:id">;
// { id: string }
type ParamsPost = RouteParams<"/users/:userId/posts/:postId">;
// { userId: string; postId: string }
// Usage dans un composant Angular :
// this.route.snapshot.params as RouteParams<"/users/:id/posts/:postId">
Pieges et bonnes pratiques
Les conditional types et infer sont puissants mais peuvent produire des comportements inattendus. Voici les pieges les plus frequents.
Piege 1 : distributivite non souhaitee
// PROBLEME : on veut verifier si un type est exactement string
// Mais la distributivite fait que string | number devient "oui" | "non"
type EstString<T> = T extends string ? "oui" : "non";
type R = EstString<string | number>; // "oui" | "non" (pas le resultat attendu !)
// SOLUTION : envelopper T dans un tuple pour desactiver la distribution
type EstStringExact<T> = [T] extends [string] ? "oui" : "non";
type R1 = EstStringExact<string>; // "oui"
type R2 = EstStringExact<string | number>; // "non" (correct !)
Piege 2 : never disparait dans les unions
// Si tous les membres d'une union produisent never, le resultat est never
// Ce n'est pas toujours le comportement attendu
type FiltrageAgressif<T> = T extends number ? T : never;
type R = FiltrageAgressif<string | boolean>; // never (attention !)
// Pour eviter never accidentel, preferer un fallback explicite
type FiltrageSur<T> = T extends number ? T : "type-non-supporte";
type R2 = FiltrageSur<string | boolean>; // "type-non-supporte" | "type-non-supporte"
// soit : "type-non-supporte"
Piege 3 : recursion infinie et complexite excessive
// TypeScript a une limite de recursion pour les types conditionnels
// Ce type peut provoquer des erreurs "Type instantiation is excessively deep"
type DiviserParDeux<N extends number, Acc extends any[] = []> =
Acc['length'] extends N
? []
: [any, ...DiviserParDeux<N, [any, ...Acc]>]; // Recursion profonde = danger
// Bonne pratique : toujours ajouter une condition d'arret claire
// et limiter la profondeur de recursion pour les types generatifs
Piege 4 : infer dans les co-positions contra-variantes
// Dans une position contra-variante (arguments de fonction),
// plusieurs infer sur le meme identifiant produisent une INTERSECTION, pas une union
type MergeArgs<T> =
T extends {
a: (x: infer U) => void;
b: (x: infer U) => void; // Meme U en position contra-variante
}
? U
: never;
// Resultat : string & number = never (intersection impossible)
type R = MergeArgs<{ a: (x: string) => void; b: (x: number) => void }>;
// never — car string & number est impossible
// En position co-variante (type de retour), TypeScript produit une UNION
type MergeReturns<T> =
T extends {
a: () => infer U;
b: () => infer U; // Meme U en position co-variante
}
? U
: never;
type R2 = MergeReturns<{ a: () => string; b: () => number }>;
// string | number (union)
Exemples pratiques avec Angular
Les conditional types et infer trouvent des applications directes dans les projets Angular, notamment pour typer les services, les resolvers et les stores.
Typer automatiquement les resolvers Angular
import { ResolveFn } from '@angular/router';
// Utility type : extraire la donnee resolue depuis un ResolveFn
// ResolveFn<T> est de type : (route, state) => T | Observable<T> | Promise<T>
type DonneesResolver<T extends ResolveFn<any>> =
T extends ResolveFn<infer Data> ? Data : never;
// Resolver concret
const utilisateurResolver: ResolveFn<{ id: number; nom: string }> =
(route) => fetch(`/api/users/${route.params['id']}`).then(r => r.json());
// Type des donnees capturees automatiquement
type DonneesUtilisateur = DonneesResolver<typeof utilisateurResolver>;
// { id: number; nom: string }
// Usage dans le composant
// this.route.snapshot.data['utilisateur'] as DonneesUtilisateur
Typer les emissions d'un EventEmitter
import { EventEmitter } from '@angular/core';
// Extraire le type des donnees emises par un EventEmitter
type TypeEmission<T> = T extends EventEmitter<infer Valeur> ? Valeur : never;
// Composant avec EventEmitter
class MonComposant {
// Emet un objet de selection
selectionChange = new EventEmitter<{ id: number; label: string }>();
}
// Capturer le type emis pour le typer dans le parent
type Selection = TypeEmission<MonComposant['selectionChange']>;
// { id: number; label: string }
// Dans le template parent Angular, le $event sera type correctement
// (selectionChange)="onSelection($event)"
// $event sera de type { id: number; label: string }
Signal Store typesafe avec infer
import { signal, computed, Signal } from '@angular/core';
// Extraire le type de valeur depuis un Signal<T>
type ValeurSignal<T> = T extends Signal<infer V> ? V : never;
// Store de panier d'achat
class PanierStore {
// Signal contenant la liste des items
readonly items = signal<{ id: number; prix: number; nom: string }[]>([]);
// Signal derive : total du panier
readonly total = computed(() =>
this.items().reduce((sum, item) => sum + item.prix, 0)
);
// Ajouter un item avec type exact
ajouter(item: ValeurSignal<typeof this.items>[number]): void {
// ValeurSignal<typeof this.items> = { id: number; prix: number; nom: string }[]
// [number] = element du tableau = { id: number; prix: number; nom: string }
this.items.update(liste => [...liste, item]);
}
}
// TypeScript infere automatiquement le type de l'argument de ajouter()
const store = new PanierStore();
store.ajouter({ id: 1, prix: 29.99, nom: "Livre TypeScript" }); // OK
// store.ajouter({ id: 2 }); // ERREUR : prix et nom manquants
Conclusion
Les conditional types et le mot-cle infer forment le coeur de l'inference avancee en TypeScript. En maitrisant la syntaxe T extends U ? X : Y, la distributivite sur les unions et les patterns d'extraction avec infer, vous pouvez creer des utility types sur mesure capables de deduire automatiquement des types complexes a partir de structures existantes.
Appliquez ces patterns progressivement : commencez par utiliser ReturnType, Parameters et Awaited au quotidien, puis explorez la creation de vos propres utility types metier quand le besoin se presente. Dans un projet Angular, ces techniques permettent de typer les resolvers, les EventEmitters et les Signals sans duplication de code.
[T] extends [U] quand vous comparez des unions entieres, et evitez les recursions profondes qui ralentissent la compilation. La lisibilite du code de types est aussi importante que sa puissance.