TypeScript avancé : types conditionnels, templates littéraux et inference
TypeScript est bien plus qu’un simple système de types basique. Une fois qu’on maîtrise les types conditionnels, les templates littéraux, et l’inférence, on peut construire des types qui s’adaptent dynamiquement à vos données.
Là où les débutants pensent “ TypeScript = ajouter : string et : number “, la vraie puissance du langage se révèle quand les types se déduisent les uns des autres. On passe d’une annotation statique à une véritable logique de niveau type, qui suit la structure de votre code métier et la fait respecter au compilateur.
Je vais vous montrer des cas concrets issus de mon code pour illustrer ces concepts.
Types conditionnels
Un type conditionnel agit comme un if au niveau du type :
type IsString<T> = T extends string ? true: false; |
La syntaxe T extends string ? true : false se lit comme “ si T est assignable à string, alors le type est true, sinon false “. C’est exactement le même esprit qu’une ternaire en valeur, mais exécuté par le compilateur. Ce mécanisme devient puissant dès que les branches ne sont plus des littéraux mais des types calculés.
Cas concret : extraire les promesses
Dans mon agent autonome, j’avais besoin d’unifier des retours synchrones et asynchrones :
type AwaitResult<T> = T extends Promise<infer U> ? U : T; |
C’est le cas d’usage le plus courant du type conditionnel combiné à infer : “ si on me donne une promesse, déballée-la et donne-moi son contenu ; sinon, renvoie-moi tel quel “. Cette petite brique permet d’écrire des fonctions génériques qui acceptent indifféremment des valeurs ou des promesses et produisent toujours le bon type de sortie.
Union distribution
Quand on passe une union à un type conditionnel, il distribue automatiquement :
type ToArray<T> = T extends unknown ? T[] : never; |
La distribution est un comportement souvent surprenant mais très utile : le conditionnel est appliqué à chaque membre de l’union séparément, puis les résultats sont réunis en une union. C’est ce qui rend les utilitaires comme Exclude ou Extract possibles.
Pour éviter la distribution, on encapsule le type dans un tuple :
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never; |
En enveloppant T dans [T], on empêche l’analyse de le considérer comme une union à distribuer : le compilateur voit un tuple, pas une union. C’est une astuce simple mais essentielle quand on veut traiter l’union comme un tout, par exemple pour vérifier qu’un type en englobe un autre.
Inférence avec infer
Le mot-clé infer permet de capturer un type dans une condition pour le réutiliser.
C’est le mécanisme qui rend les types conditionnels vraiment intéressants : au lieu de seulement tester une forme (T extends ...), on peut en extraire une partie (infer U) et la nommer pour la partie droite du type.
Extraire le type d’un tableau
type ArrayItem<T> = T extends Array<infer U> ? U : never; |
Array<infer U> capture le type des éléments d’un tableau quelle que soit sa nature. Si T est string[], alors U vaut string. Ce pattern est à la base de l’utilitaire natif Awaited et de nombreux autres.
Cas concret : typage d’une API
J’ai utilisé infer pour typer automatiquement les retours d’une API Express :
type ApiResponse<T> = { |
Notez la signature : get<T>() est générique, mais le type retourné est ApiResponse<T> : un data: T dont la nature dépend du paramètre de type qu’on fournit à l’appel. En passant <User>, on obtient directement user.data: User, avec l’autocomplétion et la vérification à la clé. C’est le typage de bout en bout d’un appel réseau sans aucune assertion manuelle.
Inférer les paramètres d’une fonction
type Parameters<T> = T extends (...args: infer P) => unknown ? P : never; |
Ces deux utilitaires, aujourd’hui fournis par TypeScript lui-même, montrent la puissance d’infer sur les signatures de fonctions : (...args: infer P) capture le tuple des paramètres, (...args: unknown[]) => infer R capture le type de retour. On peut ainsi écrire des fonctions d’ordre supérieur qui préservent les types de la fonction qu’elles enveloppent.
Templates littéraux
Les template literal types permettent de construire des types à partir de chaînes dynamiques.
Là où un type de chaîne normal ne peut être que string ou un ensemble de littéraux, les templates littéraux permettent d’exprimer une forme de chaîne : tout ce qui commence par /api/, suivie de n’importe quelle chaîne, par exemple. C’est un niveau d’expressivité qui change profondément la façon de typer le routing.
Routes typées
C’est la fonctionnalité que j’utilise le plus au quotidien :
type Route = `/api/${string}`; |
Le type /api/${string} ne matche que les chaînes commençant par /api/. Tout ce qui n’a pas ce préfixe est refusé à la compilation. Cela élimine toute une classe de fautes de frappe dans les URL de fetch ou les routes.
Cas concret : API REST typée
J’ai typé toutes les routes de mon API REST avec des templates littéraux :
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; |
On combine ici trois notions : des unions (HttpMethod, Entity), un type conditionnel (pour distinguer les méthodes qui portent un id de celles qui n’en portent pas), et un template littéral (pour construire la route). Le résultat : le compilateur refuse une route mal formée avant même qu’elle atteigne le réseau.
Manipulation de chaînes de types
TypeScript fournit des utilitaires pour transformer les chaînes :
type EventName = "user:created" | "user:updated" | "post:deleted"; |
Le pattern le plus puissant ici est T extends \${infer Entity}:${string}`: on *déconstruit* une chaîne avecinfer. Entitycapture ce qui précède le:`, le reste est ignoré. C’est le même mécanisme que le destructuring en valeur, appliqué aux types. On en déduit automatiquement l’entité et l’action de n’importe quel nom d’événement.
Combiner le tout : un vrai cas d’usage
Voici comment j’ai combiné ces trois concepts pour typer mon système d’événements :
// Définition des événements |
Le point fort de cette approche : la corrélation entre le nom de l’événement et son payload. Quand on écrit on("user:created", handler), TypeScript sait que handler reçoit un { id: string; name: string }. Inversement, passer un mauvais payload à "post:published" est une erreur de compilation. C’est le typage qui garantit que votre système d’événements est cohérent, sans aucune boilerplate à chaque écoute.
Validation au runtime via les templates
On peut même faire de la validation d’inputs avec les template types :
type HexColor = `#${string}`; |
Ici, la contrainte est portée par le type : seules des chaînes commençant par # sont acceptées. C’est un exemple réussi de “ rendre les états impossibles impossibles à représenter “ : le type empêche la mauvaise utilisation à la compilation, là où on aurait sinon un bug d’affichage silencieux.
Prédicats de type avancés
Avec is, on crée des guards qui affinent le type :
function isStringArray(value: unknown): value is string[] { |
Le prédicat de type value is string[] indique au compilateur que, si la fonction retourne true, on peut traiter value comme un string[]. À l’intérieur du bloc if, TypeScript affine automatiquement le type de data. C’est le pont entre la vérification runtime et le typage statique : on vérifie une fois, et le compilateur s’en souvient ensuite.
Bonus : mapped types avec template keys
On peut créer des types à partir de clés transformées :
type Getters<T> = { |
type Setters<T> = { |
Le as dans un mapped type permet de renommer les clés pendant l’itération : pour chaque clé K de User, on génère get${Capitalize<K>}. On produit ainsi, automatiquement et sans duplication, les types getters et setters correspondant à une entité. C’est la génération de types au sens le plus littéral : le compilateur fabrique pour vous les signatures qui suivent la structure de vos données.
Conclusion
Les types conditionnels, l’inférence avec infer, et les templates littéraux sont les piliers du typage avancé en TypeScript. Ils permettent de construire des types dynamiques, auto-adaptatifs, qui suivent la logique de votre métier.
Ce qui distingue ces techniques d’un simple typage “ à plat “, c’est la corrélation : le fait de lier une entrée (un nom d’événement, une méthode HTTP, une clé d’objet) à sa sortie (un payload, une route, un type de retour). Dès que vous écrivez un système avec plusieurs cas qui doivent rester cohérents entre eux, ces patterns vous font gagner énormément en sécurité.
J’utilise ces patterns quotidiennement pour typer mes APIs, mes événements, et mes systèmes de routing. Le compilateur détecte les incohérences à la compilation plutôt qu’à l’exécution. C’est un gain de temps considérable.
Et vous, quel pattern TypeScript vous semble le plus utile au quotidien ?