Outils puissants de TypeScript : extends, infer, keyof Maps et as Remaps
Créez des types de niveau bibliothèque en combinant des conditions, de l’inférence, des boucles mappées et un remapping de clés : c’est le même ensemble d’outils utilisé par Zod et tRPC.
Après des mois d’utilisation de TypeScript, des fonctions intégrées comme Partial, Pick et Omit cessent finalement de répondre aux véritables besoins des bibliothèques : extraire le type de retour d’une fonction, transformer chaque clé ou créer des types adaptés à d’autres types. Quatre fonctionnalités combinées permettent d’atteindre ce niveau de complexité : les extends conditionnels, infer, des boucles mappées sur keyof, ainsi que le remapping de clés avec as. Prises séparément, elles sont accessibles ; ensemble, elles permettent de mettre en œuvre des patterns utilisés dans Zod, tRPC et React Router.
1. Types conditionnels avec extends
D’un point de vue typologique, extends correspond à une instruction if :
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
Lire : si T est assignable à string, retourner true ; sinon, retourner false.
Les conditions deviennent plus strictes lorsqu’elles sont distributives. Une union T est testée membre par membre :
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Result = string[] | number[] (not (string | number)[])
C’est pourquoi le filtrage des unions est rapide :
type ExcludeString<T> = T extends string ? never : T;
type NoStrings = ExcludeString<string | number | boolean>;
// NoStrings = number | boolean
never disparaît jamais des unions — c’est la même logique derrière la fonction intégrée Exclude<T, U>.
2. Extraction des formes avec infer
infer n’apparaît que à l’intérieur d’une clause extends. Il définit un espace réservé que TypeScript remplit à partir de la forme correspondante. C’est une extraction classique du type de retour :
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "Oussama" };
}type User = MyReturnType<typeof getUser>;
// User = { id: number; name: string }
Étapes : comparer typeof getUser avec (...args: any[]) => infer R, associer R au vrai retour, et obtenir finalement R.
infer fonctionne également en d’autres positions :
// Extract the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Item = ElementOf<string[]>; // string
// Extract the resolved type of a Promise
type Awaited2<T> = T extends Promise<infer U> ? U : T;
type Data = Awaited2<Promise<{ status: number }>>;
// Data = { status: number }// Extract the first argument of a function
type FirstArg<T> = T extends (arg: infer A, ...rest: any[]) => any ? A : never;
type Arg = FirstArg<(id: number, name: string) => void>; // number
La récursivité permet de déballer les promesses imbriquées :
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type Flat = DeepAwaited<Promise<Promise<Promise<string>>>>;
// Flat = string
3. Boucles avec les types mappés keyof
keyof génère une union de clés :
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"
Les types mappés itèrent sur cette union comme un for...in au niveau du type :
type Readonly2<T> = {
[K in keyof T]: T[K];
};
Ajoutez des modificateurs pour des copies optionnelles ou en lecture seule :
// Make every property optional
type Optional<T> = {
[K in keyof T]?: T[K];
};
// Make every property readonly
type ReadonlyAll<T> = {
readonly [K in keyof T]: T[K];
};// Remove readonly / optional with a minus modifier
type Mutable<T> = {
-readonly [K in keyof T]-?: T[K];
};
Transformez les valeurs pendant la boucle :
type Stringify<T> = {
[K in keyof T]: string;
};
type StringifiedUser = Stringify<User>;
// { id: string; name: string; email: string }
Combinez avec des conditions :
type NullableStrings<T> = {
[K in keyof T]: T[K] extends string ? T[K] | null : T[K];
};
type Result2 = NullableStrings<User>;
// { id: number; name: string | null; email: string | null }
4. Réassignation des clés avec as
Dans un type mappé, as calcule un nouveau nom de clé pour chaque propriété :
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
/*
{
getId: () => number;
getName: () => string;
getEmail: () => string;
}
*/
[K in keyof T] itère ; la littérale de template crée des clés de type getName ; T[K] devient le type de retour du getter.
Utilisez as never pour supprimer des clés pendant la boucle :
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K];
};
interface Mixed {
id: number;
name: string;
active: boolean;
}type OnlyNonBoolean = OmitByType<Mixed, boolean>;
// { id: number; name: string }
C’est ce extends à l’intérieur du schéma as qui permet de créer les variantes Pick/Omit basées sur des valeurs.
Tous les quatre ensemble
type Validators<T> = {
[K in keyof T as T[K] extends Function ? never : `validate${Capitalize<string & K>}`]:
(value: T[K]) => value is T[K];
};
interface ApiResponse {
id: number;
email: string;
active: boolean;
refresh: () => void;
}type ResponseValidators = Validators<ApiResponse>;
/*
{
validateId: (value: number) => value is number;
validateEmail: (value: string) => value is string;
validateActive: (value: boolean) => value is boolean;
}
*/
Décomposition : keyof fournit les clés ; la boucle de mappage visite chacune d’elles ; as renomme ou supprime via never lorsque T[K] extends Function ; extends décide si garder ou supprimer ; infer serait utilisé pour extraire des types d’arguments imbriqués.
Référence rapide
| Fonctionnalité | Rôle | Où |
|---|---|---|
extends | À niveau de type, pour les conditions | Pour comparer des types |
infer | Pour capturer la partie correspondante | Uniquement à l’intérieur de extends |
[K in keyof T]asas de mappageLorsque vous cliquez dessus, les formes complexes cessent d’imposer l’utilisation de any. Reconstituez Pick, Omit, Record et ReturnType à partir de zéro — toutes les fonctionnalités mentionnées apparaîtront — et l’outil de travail au niveau des types deviendra pratique plutôt que mystérieux. Les auteurs de bibliothèques comptent sur ces quatre mécanismes identiques ; lire leurs fichiers .d.ts devient beaucoup plus facile après avoir reconstruit ces outils à la main pendant un week-end.
Parcours de pratique
Reconstruisez Exclude à l’aide de conditions distributives, ReturnType avec infer, Partial/Readonly avec des modificateurs mappés, ainsi qu’un OmitByType personnalisé utilisant as never. Ces quatre exercices mettent en pratique chaque fonctionnalité présentée dans ce guide. Par la suite, la lecture des fichiers .d.ts d’une bibliothèque ne ressemblera plus à du code golf, mais plutôt à du TypeScript ordinaire — uniquement au niveau des types.
Lorsqu’une erreur de type se transforme en une série interminable de conditions, procédez par bisection : remplacez temporairement un type mappé par un type d’objet concret, ou remplacez infer R par une annotation explicite, jusqu’à ce que le branchement défaillant devienne évident. Le débogage au niveau des types reste du débogage ; la même méthode de division et de conquête s’applique.
Préférez les alias nommés aux longues lignes de code afin que les futurs lecteurs puissent comprendre l’intention. Exportez uniquement les outils que votre application réutilise réellement ; conservez les utilitaires spéculatifs dans un environnement isolé en attendant qu’un autre point d’appel apparaisse. Cela permet de garder la surface des types aussi intentionnelle que l’API en temps de exécution.