Dépassez les typages de base. Maîtrisez les fonctionnalités avancées de TypeScript comme les types conditionnels, les littéraux de modèle et la manipulation de chaînes pour créer des API robustes et sûres. Guide pour les développeurs.
Libérer tout le potentiel de TypeScript : un examen approfondi des types conditionnels, des littéraux de modèle et de la manipulation avancée des chaînes de caractères
Dans le monde du développement logiciel moderne, TypeScript a évolué bien au-delà de son rôle initial de simple vérificateur de type pour JavaScript. Il est devenu un outil sophistiqué pour ce qui peut être décrit comme une programmation au niveau du type. Ce paradigme permet aux développeurs d’écrire du code qui fonctionne sur les types eux-mêmes, créant des API dynamiques, auto-documentées et remarquablement sûres. Au cœur de cette révolution se trouvent trois fonctionnalités puissantes travaillant de concert : les types conditionnels, les types littéraux de modèle et une suite de types intrinsèques de manipulation de chaînes.
Pour les développeurs du monde entier qui cherchent à améliorer leurs compétences TypeScript, la compréhension de ces concepts n’est plus un luxe : c’est une nécessité pour créer des applications évolutives et maintenables. Ce guide vous emmènera dans un examen approfondi, en commençant par les principes fondamentaux et en allant jusqu’à des modèles complexes du monde réel qui démontrent leur puissance combinée. Que vous construisiez un système de conception, un client API de type sûr ou une bibliothèque complexe de gestion des données, la maîtrise de ces fonctionnalités changera fondamentalement votre façon d’écrire du TypeScript.
Les bases : les types conditionnels (le ternaire `extends`)
À la base, un type conditionnel vous permet de choisir l’un des deux types possibles en fonction d’une vérification de la relation de type. Si vous connaissez l’opérateur ternaire de JavaScript (condition ? valeurSiVrai : valeurSiFaux), vous trouverez la syntaxe immédiatement intuitive :
type Result = SomeType extends OtherType ? TrueType : FalseType;
Ici, le mot clé extends agit comme notre condition. Il vérifie si SomeType est affectable à OtherType. Décomposons-le avec un exemple simple.
Exemple de base : vérification d’un type
Imaginez que nous voulons créer un type qui se résout en true si un type donné T est une chaîne, et false sinon.
type IsString
Nous pouvons ensuite utiliser ce type comme suit :
type A = IsString<"hello">; // type A est true
type B = IsString<123>; // type B est false
C’est le bloc de construction fondamental. Mais la véritable puissance des types conditionnels est libérée lorsqu’elle est combinée avec le mot clé infer.
La puissance de `infer` : extraction des types de l’intérieur
Le mot clé infer change la donne. Il vous permet de déclarer une nouvelle variable de type générique dans la clause extends, en capturant efficacement une partie du type que vous vérifiez. Considérez-le comme une déclaration de variable au niveau du type qui obtient sa valeur à partir de la correspondance de modèle.
Un exemple classique consiste à déballer le type contenu dans une Promise.
type UnwrapPromise
Analysons ceci :
T extends Promise : ceci vérifie siTest unePromise. Si c’est le cas, TypeScript tente de faire correspondre la structure.infer U : si la correspondance réussit, TypeScript capture le type vers lequel laPromisese résout et le place dans une nouvelle variable de type nomméeU.? U : T : si la condition est vraie (Tétait unePromise), le type résultant estU(le type déballé). Sinon, le type résultant est simplement le type d’origineT.
Utilisation :
type User = { id: number; name: string; };
type UserPromise = Promise
type UnwrappedUser = UnwrapPromise
type UnwrappedNumber = UnwrapPromise
Ce modèle est si courant que TypeScript inclut des types d’utilitaires intégrés comme ReturnType, qui est implémenté en utilisant le même principe pour extraire le type de retour d’une fonction.
Types conditionnels distributifs : travailler avec des unions
Un comportement fascinant et crucial des types conditionnels est qu’ils deviennent distributifs lorsque le type vérifié est un paramètre de type générique « nu ». Cela signifie que si vous lui passez un type d’union, la condition sera appliquée à chaque membre de l’union individuellement, et les résultats seront collectés dans une nouvelle union.
Considérez un type qui convertit un type en un tableau de ce type :
type ToArray
Si nous passons un type d’union à ToArray :
type StrOrNumArray = ToArray
Le résultat n’est pas (string | number)[]. Parce que T est un paramètre de type nu, la condition est distribuée :
ToArraydevientstring[]ToArraydevientnumber[]
Le résultat final est l’union de ces résultats individuels : string[] | number[].
Cette propriété distributive est incroyablement utile pour filtrer les unions. Par exemple, le type d’utilitaire intégré Extract l’utilise pour sélectionner les membres de l’union T qui sont assignables à U.
Si vous devez empêcher ce comportement distributif, vous pouvez envelopper le paramètre de type dans un tuple des deux côtés de la clause extends :
type ToArrayNonDistributive
type StrOrNumArrayUnified = ToArrayNonDistributive
Avec cette base solide, explorons comment nous pouvons construire des types de chaînes dynamiques.
Construction de chaînes dynamiques au niveau du type : types littéraux de modèle
Introduits dans TypeScript 4.1, les types littéraux de modèle vous permettent de définir des types qui ressemblent aux chaînes littérales de modèle de JavaScript. Ils vous permettent de concaténer, de combiner et de générer de nouveaux types littéraux de chaînes à partir de ceux existants.
La syntaxe est exactement ce à quoi vous vous attendez :
type World = "World";
type Greeting = `Hello, ${World}!`; // type Greeting est "Hello, World!"
Cela peut sembler simple, mais sa puissance réside dans sa combinaison avec des unions et des génériques.
Unions et permutations
Lorsqu’un type littéral de modèle implique une union, il s’étend à une nouvelle union contenant toutes les permutations de chaînes possibles. C’est un moyen puissant de générer un ensemble de constantes bien définies.
Imaginez définir un ensemble de propriétés de marge CSS :
type Side = "top" | "right" | "bottom" | "left";
type MarginProperty = `margin-${Side}`;
Le type résultant pour MarginProperty est :
"margin-top" | "margin-right" | "margin-bottom" | "margin-left"
Ceci est parfait pour créer des accessoires de composant de type sûr ou des arguments de fonction où seuls des formats de chaînes spécifiques sont autorisés.
Combinaison avec des génériques
Les littéraux de modèle brillent vraiment lorsqu’ils sont utilisés avec des génériques. Vous pouvez créer des types d’usine qui génèrent de nouveaux types littéraux de chaînes en fonction d’une entrée.
type MakeEventListener
type UserListener = MakeEventListener<"user">; // "onUserChange"
type ProductListener = MakeEventListener<"product">; // "onProductChange"
Ce modèle est la clé de la création d’API dynamiques et de type sûr. Mais que se passe-t-il si nous devons modifier la casse de la chaîne, comme changer "user" en "User" pour obtenir "onUserChange" ? C’est là que les types de manipulation de chaînes entrent en jeu.
La boîte à outils : types intrinsèques de manipulation de chaînes
Pour rendre les littéraux de modèle encore plus puissants, TypeScript fournit un ensemble de types intégrés pour manipuler les littéraux de chaînes. Ce sont comme des fonctions d’utilité, mais pour le système de types.
Modificateurs de casse : `Uppercase`, `Lowercase`, `Capitalize`, `Uncapitalize`
Ces quatre types font exactement ce que leurs noms suggèrent :
Uppercase : convertit l’ensemble du type de chaîne en majuscules.type LOUD = Uppercase<"hello">; // "HELLO"Lowercase : convertit l’ensemble du type de chaîne en minuscules.type quiet = Lowercase<"WORLD">; // "world"Capitalize : convertit le premier caractère du type de chaîne en majuscules.type Proper = Capitalize<"john">; // "John"Uncapitalize : convertit le premier caractère du type de chaîne en minuscules.type variable = Uncapitalize<"PersonName">; // "personName"
Revenons à notre exemple précédent et améliorons-le en utilisant Capitalize pour générer des noms de gestionnaires d’événements conventionnels :
type MakeEventListener
type UserListener = MakeEventListener<"user">; // "onUserChange"
type ProductListener = MakeEventListener<"product">; // "onProductChange"
Maintenant, nous avons toutes les pièces. Voyons comment elles se combinent pour résoudre des problèmes complexes du monde réel.
La synthèse : combiner les trois pour les modèles avancés
C’est là que la théorie rencontre la pratique. En combinant les types conditionnels, les littéraux de modèle et la manipulation de chaînes, nous pouvons créer des définitions de types incroyablement sophistiquées et sûres.
Modèle 1 : l’émetteur d’événements entièrement de type sûr
Objectif : créer une classe EventEmitter générique avec des méthodes comme on(), off() et emit() qui sont entièrement de type sûr. Cela signifie :
- Le nom de l’événement transmis aux méthodes doit être un événement valide.
- La charge utile transmise Ă
emit()doit correspondre au type dĂ©fini pour cet Ă©vĂ©nement. - La fonction de rappel transmise Ă
on()doit accepter le type de charge utile correct pour cet événement.
Tout d’abord, nous définissons une carte des noms d’événements à leurs types de charge utile :
interface EventMap {
"user:created": { userId: number; name: string; };
"user:deleted": { userId: number; };
"product:added": { productId: string; price: number; };
}
Maintenant, nous pouvons construire la classe EventEmitter générique. Nous utiliserons un paramètre générique Events qui doit étendre notre structure EventMap.
class TypedEventEmitter
private listeners: { [K in keyof Events]?: ((payload: Events[K]) => void)[] } = {};
// La méthode `on` utilise un `K` générique qui est une clé de notre carte Events
on
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]?.push(callback);
}
// La méthode `emit` garantit que la charge utile correspond au type de l’événement
emit
this.listeners[event]?.forEach(callback => callback(payload));
}
}
Instancions-le et utilisons-le :
const appEvents = new TypedEventEmitter
// Ceci est de type sûr. La charge utile est correctement déduite comme { userId: number; name: string; }
appEvents.on("user:created", (payload) => {
console.log(`User created: ${payload.name} (ID: ${payload.userId})`);
});
// TypeScript signalera une erreur ici car "user:updated" n’est pas une clé dans EventMap
// appEvents.on("user:updated", () => {}); // Erreur !
// TypeScript signalera une erreur ici car la charge utile ne contient pas la propriété "name"
// appEvents.emit("user:created", { userId: 123 }); // Erreur !
Ce modèle offre une sécurité au moment de la compilation pour ce qui est traditionnellement une partie très dynamique et sujette aux erreurs de nombreuses applications.
Modèle 2 : accès au chemin de type sûr pour les objets imbriqués
Objectif : créer un type d’utilitaire, PathValue, qui peut déterminer le type d’une valeur dans un objet imbriqué T à l’aide d’un chemin de chaîne de notation pointée P (par exemple, "user.address.city").
Il s’agit d’un modèle très avancé qui met en valeur les types conditionnels récursifs.
Voici l’implémentation, que nous allons décomposer :
type PathValue
? Key extends keyof T
? PathValue
: never
: P extends keyof T
? T[P]
: never;
Traçons sa logique avec un exemple : PathValue
- Appel initial :
Pest"a.b.c". Cela correspond au littéral de modèle`${infer Key}.${infer Rest}`. Keyest déduit comme"a".Restest déduit comme"b.c".- Première récursion : le type vérifie si
"a"est une clé deMyObject. Si oui, il appelle récursivementPathValue. - Deuxième récursion : maintenant,
Pest"b.c". Il correspond à nouveau au littéral de modèle. Keyest déduit comme"b".Restest déduit comme"c".- Le type vérifie si
"b"est une clé deMyObject["a"]et appelle récursivementPathValue. - Cas de base : enfin,
Pest"c". Cela ne correspond pas Ă`${infer Key}.${infer Rest}`. La logique de type retombe sur la deuxième conditionnelle :P extends keyof T ? T[P] : never. - Le type vĂ©rifie si
"c"est une clé deMyObject["a"]["b"]. Si oui, le résultat estMyObject["a"]["b"]["c"]. Sinon, c’estnever.
Utilisation avec une fonction d’assistance :
declare function get
const myObject = {
user: {
name: "Alice",
address: {
city: "Wonderland",
zip: 12345
}
}
};
const city = get(myObject, "user.address.city"); // const city: string
const zip = get(myObject, "user.address.zip"); // const zip: number
const invalid = get(myObject, "user.email"); // const invalid: never
Ce type puissant empêche les erreurs d’exécution dues aux fautes de frappe dans les chemins et fournit une inférence de type parfaite pour les structures de données profondément imbriquées, un défi courant dans les applications mondiales traitant des réponses d’API complexes.
Meilleures pratiques et considérations relatives aux performances
Comme pour tout outil puissant, il est important d’utiliser ces fonctionnalités à bon escient.
- Prioriser la lisibilité : les types complexes peuvent rapidement devenir illisibles. Décomposez-les en types d’assistance plus petits et bien nommés. Utilisez des commentaires pour expliquer la logique, tout comme vous le feriez avec du code d’exécution complexe.
- Comprendre le type `never`Â : le type
neverest votre principal outil pour gérer les états d’erreur et filtrer les unions dans les types conditionnels. Il représente un état qui ne devrait jamais se produire. - Méfiez-vous des limites de récursion : TypeScript a une limite de profondeur de récursion pour l’instanciation de type. Si vos types sont trop profondément imbriqués ou infiniment récursifs, le compilateur signalera une erreur. Assurez-vous que vos types récursifs ont un cas de base clair.
- Surveiller les performances de l’IDE : les types extrêmement complexes peuvent parfois avoir un impact sur les performances du serveur de langage TypeScript, ce qui entraîne un ralentissement de la saisie semi-automatique et de la vérification de type dans votre éditeur. Si vous constatez des ralentissements, voyez si un type complexe peut être simplifié ou décomposé.
- Savoir quand s’arrêter : ces fonctionnalités sont destinées à résoudre des problèmes complexes de sécurité de type et d’expérience développeur. Ne les utilisez pas pour sur-concevoir des types simples. L’objectif est d’améliorer la clarté et la sécurité, et non d’ajouter une complexité inutile.
Conclusion
Les types conditionnels, les littéraux de modèle et les types de manipulation de chaînes ne sont pas que des fonctionnalités isolées ; ils constituent un système étroitement intégré pour effectuer une logique sophistiquée au niveau du type. Ils nous permettent de dépasser les simples annotations et de construire des systèmes qui sont profondément conscients de leur propre structure et de leurs propres contraintes.
En maîtrisant ce trio, vous pouvez :
- Créer des API auto-documentées : les types eux-mêmes deviennent la documentation, guidant les développeurs vers leur utilisation correcte.
- Éliminer des classes entières de bogues : les erreurs de type sont détectées au moment de la compilation, et non par les utilisateurs en production.
- Améliorer l’expérience développeur : profitez d’une saisie semi-automatique riche et de messages d’erreur en ligne, même pour les parties les plus dynamiques de votre code.
L’adoption de ces fonctionnalités avancées transforme TypeScript d’un filet de sécurité en un partenaire puissant dans le développement. Il vous permet d’encoder une logique métier complexe et des invariants directement dans le système de types, garantissant que vos applications sont plus robustes, maintenables et évolutives pour un public mondial.