TypeScript conditional types : T extends U ? X : Y, mot-cle infer, distributivite unions, pattern matching, types recursifs, ReturnType et Awaited.
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
Distributivité — la mécanique sous-jacente des conditional types
Quand vous écrivez T extends U ? X : Y avec T étant une union (`A | B | C`), TypeScript distribue automatiquement la condition sur chaque membre. C'est ce qui rend Exclude et Extract possibles avec seulement quelques lignes de code.
// Definition de Exclude built-in
type Exclude<T, U> = T extends U ? never : T;
// Comment ça marche avec une union
type Result = Exclude<'a' | 'b' | 'c', 'a' | 'b'>;
// Étape 1 : distribution sur chaque membre
// = ('a' extends 'a' | 'b' ? never : 'a')
// | ('b' extends 'a' | 'b' ? never : 'b')
// | ('c' extends 'a' | 'b' ? never : 'c')
// Étape 2 : évaluation
// = never | never | 'c'
// Étape 3 : never s'élimine d'une union
// = 'c'
Désactiver la distributivité avec un wrapper tuple
// Comparer des unions ENTIÈRES (pas membre par membre)
type IsExactly<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
type T1 = IsExactly<string, string>; // true
type T2 = IsExactly<string | number, string>; // false
type T3 = IsExactly<'a' | 'b', 'a' | 'b'>; // true
// Sans le wrapper, ça donnerait un résultat distribué incorrect
type Bad = string | number extends string ? true : false; // distribué = boolean
Cas réel — typer le retour d'un router
// Pattern typique dans les routers modernes (Remix, tRPC)
type RouteParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof RouteParams<Rest>]: string }
: Path extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type UserPostParams = RouteParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string } — extrait automatiquement les params
Pattern matching avec infer — extraction de types
infer agit comme un pattern matching de types : il capture une partie d'une structure complexe pour la réutiliser. Les utilisations vont bien au-delà du simple ReturnType.
Extraire le type d'élément d'un tableau
type ArrayElement<T> = T extends (infer E)[] ? E : never;
type T1 = ArrayElement<number[]>; // number
type T2 = ArrayElement<[string, boolean]>; // string | boolean
type T3 = ArrayElement<User[]>; // User
Extraire la première lettre d'un string littéral
type FirstChar<S extends string> = S extends `${infer First}${string}` ? First : never;
type C1 = FirstChar<'Hello'>; // 'H'
type C2 = FirstChar<'a'>; // 'a'
type C3 = FirstChar<''>; // never
Camelisation de string littéraux (snake_case → camelCase)
type CamelCase<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<CamelCase<Tail>>}`
: S;
type T1 = CamelCase<'user_first_name'>; // 'userFirstName'
type T2 = CamelCase<'api_response_code'>; // 'apiResponseCode'
Inférer le type d'un événement DOM par nom
// HTMLElementEventMap est l'interface standard du DOM
type EventType<K extends keyof HTMLElementEventMap> =
HTMLElementEventMap[K];
function addTypedListener<K extends keyof HTMLElementEventMap>(
el: HTMLElement,
event: K,
handler: (e: EventType<K>) => void,
) {
el.addEventListener(event, handler);
}
addTypedListener(button, 'click', (e) => {
e.clientX; // typé number automatiquement
});
Limites — quand TypeScript craque
Les conditional types et recursive types sont puissants mais ont des limites strictes du compilateur. Connaître ces limites évite de perdre des heures sur du code qui paraît correct mais ne compile pas.
Limite de profondeur récursive
TypeScript limite la profondeur de récursion à environ 50-100 niveaux selon la version. Le compilateur lève l'erreur Type instantiation is excessively deep and possibly infinite. La solution est généralement de :
- Ajouter une condition d'arrêt explicite (cas de base + cas récursif clairement séparés)
- Utiliser
extends neverpour rompre la récursion sur les types ambigus - Limiter la profondeur via un compteur tuple (accumulateur)
// Compteur tuple pour limiter la profondeur
type Repeat<T, N extends number, Acc extends T[] = []> =
Acc['length'] extends N ? Acc : Repeat<T, N, [...Acc, T]>;
type Five = Repeat<string, 5>; // [string, string, string, string, string]
Performance du compilateur
Les types très complexes ralentissent tsc --noEmit et l'autocomplétion IDE. Sur un projet avec beaucoup de types conditionnels récursifs, le check peut passer de 2s à 30s. Outil de diagnostic : tsc --extendedDiagnostics affiche le temps par phase et le nombre d'instantiations de types. Si un fichier de types prend > 1s, c'est suspect.
Préférer les utility built-in
Les utility types officiels (Pick, Omit, Partial, Required, Readonly, Exclude, Extract, NonNullable, Parameters, ReturnType, Awaited) sont implémentés dans lib.es5.d.ts avec des optimisations internes du compilateur. Réutilisez-les avant d'écrire les vôtres — c'est plus rapide à compiler et plus lisible pour vos collègues.
Mini-projet appliqué — parseur de routes type-safe
Voici le cas concret où infer + conditional types démontrent toute leur puissance : un parseur de routes URL qui extrait les paramètres, les types et le typage du store associé uniquement à la compilation. C'est exactement ce qui propulse les libs comme tRPC, React Router v7, et l'i18n typée moderne — code que vous pouvez écrire vous-même en une centaine de lignes.
1. Extraire les paramètres d'une route avec infer
Pour les bases des conditional types et template literal types, voir aussi le guide des mapped types et template literal types.
// Extrait les segments :name d'un path arbitraire
type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
type P1 = ExtractParams<'/users/:userId'>;
// 'userId'
type P2 = ExtractParams<'/orgs/:orgId/projects/:projectId/issues/:issueId'>;
// 'orgId' | 'projectId' | 'issueId'
type P3 = ExtractParams<'/dashboard'>;
// never
2. Typer le shape complet des params en Record
type RouteParams<Path extends string> =
ExtractParams<Path> extends never
? Record<string, never>
: { [K in ExtractParams<Path>]: string };
type Test1 = RouteParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }
3. Typer aussi les query params (séparateur ?)
// Découpe le path et la query
type SplitPath<Path extends string> =
Path extends `${infer Base}?${infer Query}`
? { base: Base; query: Query }
: { base: Path; query: '' };
type ExtractQueryKeys<Q extends string> =
Q extends `${infer Key}=${string}&${infer Rest}`
? Key | ExtractQueryKeys<Rest>
: Q extends `${infer Key}=${string}`
? Key
: never;
type FullRouteParams<Path extends string> =
SplitPath<Path> extends { base: infer B extends string; query: infer Q extends string }
? RouteParams<B> & {
[K in ExtractQueryKeys<Q>]?: string;
}
: never;
type T = FullRouteParams<'/users/:userId?tab=&sort='>;
// { userId: string; tab?: string; sort?: string }
4. Inférer le type de retour selon la route (Result<T, E>)
// Registry typé des endpoints
type RouteRegistry = {
'/users': { method: 'GET'; response: { id: string; name: string }[] };
'/users/:userId': { method: 'GET'; response: { id: string; name: string; email: string } };
'/orders/:orderId/items/:itemId': {
method: 'GET';
response: { id: string; quantity: number };
};
'/users': { method: 'POST'; body: { name: string }; response: { id: string } };
};
// Conditional + infer pour extraire la réponse de chaque route
type ResponseOf<Path extends keyof RouteRegistry> =
RouteRegistry[Path] extends { response: infer R } ? R : never;
// Conditional pour détecter les routes nécessitant un body
type HasBody<Path extends keyof RouteRegistry> =
RouteRegistry[Path] extends { body: infer B } ? B : never;
// Client final type-safe
function call<P extends keyof RouteRegistry>(
path: P,
params: RouteParams<P & string>,
body?: HasBody<P>,
): Promise<ResponseOf<P>> {
throw new Error('TODO');
}
const user = call('/users/:userId', { userId: 'u-42' });
// user typé : Promise<{ id: string; name: string; email: string }>
:userId, oubli d'un param, mauvais type) qui représentaient ~14 % des bugs runtime côté frontend. Le coût compile-time reste négligeable (~8 ms pour 47 routes typées, mesuré avec tsc --extendedDiagnostics).
5. Parseur de format de date au niveau types
Application moins courante mais spectaculaire : un parseur de format type-safe. Pour les patterns similaires, voir aussi le guide d'organisation des modules dans un projet TS.
// Parse un format style 'YYYY-MM-DD HH:mm' en tokens
type ParseFormat<F extends string> =
F extends `${infer Token}-${infer Rest}`
? [Token, ...ParseFormat<`${Rest}`>]
: F extends `${infer Token} ${infer Rest}`
? [Token, ...ParseFormat<`${Rest}`>]
: F extends `${infer Token}:${infer Rest}`
? [Token, ...ParseFormat<`${Rest}`>]
: [F];
type Tokens = ParseFormat<'YYYY-MM-DD HH:mm'>;
// ['YYYY', 'MM', 'DD', 'HH', 'mm']
// Validation à la compilation : chaque token doit être connu
type ValidToken = 'YYYY' | 'MM' | 'DD' | 'HH' | 'mm' | 'ss';
type AreAllValid<Tokens extends readonly string[]> =
Tokens[number] extends ValidToken ? true : false;
type Check = AreAllValid<Tokens>; // true
6. Limites pratiques à connaître
- Profondeur de récursion limitée : ~50-100 niveaux. Pour les longues routes (10+ params), TypeScript peut lever "Type instantiation is excessively deep".
- Performance des unions très larges : un registry de > 200 routes peut ralentir l'IDE. Solution : segmenter par feature (UsersRoutes, OrdersRoutes) puis composer.
- Pas de regex types : impossible d'écrire un type qui valide qu'une string match
/^[a-z0-9]+$/. TypeScript ne parse pas les regex au niveau types — utiliser Zod runtime pour ce besoin. - Inférence parfois trop large : sans
consttype parameter (TS 5.0+), le typage des paths littéraux peut s'élargir àstring. Voir les nouveautés TS 5.x pour le détail.
Pour aller plus loin sur les patterns combinés (validation runtime + typage compile-time), lire également le guide du strict mode qui complète ces patterns côté configuration.
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.