TypeScript avancé avec un exemple réel (Partie 2)
Publié: 2020-11-20Il est temps de continuer (et, espérons-le, de terminer) notre didacticiel TypeScript. Si vous avez manqué les articles précédents que nous avons écrits sur TypeScript, les voici : notre introduction initiale à TypeScript, et la première partie de ce tutoriel où j'explique l'exemple JavaScript avec lequel nous travaillons et les étapes que nous avons prises pour l'améliorer partiellement .
Aujourd'hui nous allons terminer notre exemple en complétant tout ce qui manque encore. Plus précisément, nous verrons d'abord comment créer des types qui sont des versions partielles d'autres types existants. Nous verrons ensuite comment taper correctement les actions d'un store Redux en utilisant les unions de type, et nous aborderons les avantages offerts par les unions de type. Et, enfin, je vais vous montrer comment créer une fonction polymorphe dont le type de retour dépend de ses arguments.
Un bref aperçu de ce que nous avons fait jusqu'à présent…
Dans la première partie du didacticiel, nous avons utilisé (une partie de) un magasin Redux que nous avons pris de Nelio Content comme exemple de travail. Tout a commencé comme du code JavaScript simple qui a dû être amélioré en ajoutant des types concrets qui le rendent plus robuste et intelligible. Ainsi, par exemple, nous avons défini les types suivants :
type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary<PostId, Post>; days: Dictionary<Day, PostId[]>; }; ce qui nous a aidés à comprendre, en un coup d'œil, le type d'informations avec lesquelles notre magasin travaille. Dans ce cas particulier, par exemple, nous pouvons voir que l'état de notre application stocke deux choses : une liste de posts (que nous avons indexées via leur PostId ) et une structure appelée days qui, à partir d'un certain jour, renvoie une liste de identifiants de poste. Nous pouvons également voir les attributs (et leurs types spécifiques) que nous trouverons dans un objet Post .
Une fois ces types définis, nous avons édité toutes les fonctions de notre exemple pour les utiliser. Cette tâche simple a transformé les signatures de fonction opaques de JavaScript :
// Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }aux signatures de fonction TypeScript explicites :
// Selectors function getPost( state: State, id: PostId ): Post | undefined { ... } function getPostsInDay( state: State, day: Day ): PostId[] { ... } // Actions function receiveNewPost( post: Post ): any { ... } function updatePost( postId: PostId, attributes: any ): any { ... } // Reducer function reducer( state: State, action: any ): State { ... } La fonction getPostsInDay est un très bon exemple de la façon dont TypeScript améliorera la qualité de votre code. Si vous regardez la contrepartie JavaScript, vous ne savez vraiment pas ce que cette fonction va retourner. Bien sûr, son nom peut faire allusion au type de résultat (est-ce une liste de messages, peut-être ?), mais vous devez regarder le code source de la fonction (et probablement aussi les actions et les réducteurs) pour être sûr (c'est en fait une liste de identifiants de poste). On peut améliorer cette situation en nommant mieux les choses ( getIdsOfPostsInDay , par exemple), mais rien de tel que des types concrets pour lever tout doute : PostId[] .
Donc, maintenant que vous êtes au courant de l'état actuel des choses, il est temps de passer à autre chose et de corriger tout ce que nous avons ignoré la semaine dernière. Plus précisément, nous savons que nous devons taper les attributs attributes de la fonction updatePost et nous devons définir les types de nos actions (notez que dans reducer , l'attribut action est actuellement de type any ).
Comment taper un objet dont les attributs sont un sous-ensemble d'un autre objet
Échauffons-nous en commençant par quelque chose de simple. La fonction updatePost génère une action qui signale notre intention de mettre à jour certains attributs d'un ID de publication donné. Voici à quoi cela ressemble :
function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }et voici comment l'action est utilisée par le réducteur pour mettre à jour la publication dans notre boutique :
function reducer( state: State, action: any ): State { // ... switch ( action.type ) { // ... case 'UPDATE_POST': if ( ! state.posts[ action.postId ] ) { return state; } const post = { ...state.posts[ action.postId ], ...action.attributes, }; return { ... }; } // ... }Comme vous pouvez le voir, le réducteur recherche la publication dans le magasin et, s'il y est, il met à jour ses attributs en les écrasant à l'aide de ceux inclus dans l'action.
Mais quels sont exactement les attributes d'une action ? Eh bien, ils sont clairement quelque chose qui ressemble à un Post , car ils sont censés écraser les attributs que nous pouvons trouver dans un post :
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };mais si nous essayons d'utiliser ceci, nous verrons que cela ne fonctionne pas :
const post: Post = { id: 1, title: 'Title', author: 'Ruth', day: '2020-10-01', status: 'draft', isSticky: false, }; const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; parce que nous ne voulons pas que les attributes soient eux-mêmes une Post ; nous voulons qu'il s'agisse d'un sous-ensemble d'attributs Post (c'est-à-dire que nous voulons spécifier uniquement les attributs d'un objet Post que nous allons écraser).
Pour résoudre ce problème, il suffit d'utiliser le type d'utilitaire Partial :
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };Et c'est tout! Ou est-ce?
Filtrage explicite des attributs
L'extrait de code précédent est toujours défectueux, car il est possible d'obtenir des erreurs d'exécution que le compilateur de TypeScript ne vérifie pas. Voici pourquoi : l'action qui signale une mise à jour de publication a deux arguments, un ID de publication et l'ensemble des attributs que nous voulons mettre à jour. Une fois que l'action est prête, le réducteur est chargé d'écraser le message existant avec les nouvelles valeurs :
const post = { ...state.posts[ action.postId ], ...action.attributes, }; Et c'est précisément la partie défectueuse de notre code ; il est possible que l'attribut postId de l'action ait un pots ID x et que l'attribut id dans les attributes ait un autre post ID y :
const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, }; Il s'agit évidemment d'une action valide, et donc TypeScript ne déclenche aucune erreur, mais nous savons que cela ne devrait pas l'être. L'attribut id dans les attributes (s'il est présent) et l'attribut postId doivent avoir la même valeur, sinon nous avons une action incohérente. Notre type d'action est imprécis car il nous permet de définir une situation qui devrait être impossible… alors comment pouvons-nous résoudre ce problème ? Assez facilement : il suffit de changer ce type pour que ce scénario qui devait être impossible devienne réellement impossible.
La première solution à laquelle j'ai pensé est la suivante : supprimer l'attribut postId de l'action et ajouter l'ID dans l' attributes attributs :
type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; } Ensuite, mettez à jour votre réducteur afin qu'il utilise action.attributes.id au lieu d' action.postId pour rechercher et écraser la publication existante.
Malheureusement, cette solution n'est pas idéale, car les attributes sont une « publication partielle », vous vous souvenez ? Cela signifie qu'en théorie, l'attribut id peut ou non être dans l'objet attributes . Bien sûr, nous savons qu'il sera là, car c'est nous qui générons l'action… mais nos types sont encore imprécis. Si à l'avenir quelqu'un modifie la fonction updatePost et ne s'assure pas que les attributes incluent le postId , l'action résultante serait valide selon TypeScript mais notre code ne fonctionnerait pas :
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Donc, si nous voulons que TypeScript nous protège, nous devons être aussi précis que possible lors de la spécification des types et nous assurer qu'ils rendent impossibles les états impossibles. Compte tenu de tout cela, nous n'avons que deux options disponibles :
- Si nous avons un attribut
postIden action (comme nous l'avons fait au début), alors l'objetattributesne doit pas contenir d'attributid. - Si, d'autre part, l'action n'a pas d'attribut
postId, alors lesattributesdoivent contenir un attributid.
La première solution peut être facilement spécifiée à l'aide d'un autre type d'utilitaire, Omit , qui nous permet de créer un nouveau type en supprimant des attributs d'un type existant :
type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };qui fonctionne comme prévu :
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, }; Pour la deuxième option, nous devons ajouter explicitement l'attribut id au-dessus du type Partial<Post> que nous avons défini :

type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };ce qui, encore une fois, nous donne le résultat attendu :
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Types de syndicats
Dans la section précédente, nous avons déjà vu comment taper l'une des deux actions de notre boutique. Faisons de même avec la deuxième action. Sachant que receiveNewPost ressemble à ceci :
function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }son type peut être défini comme suit :
type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };Facile, non ?
Intéressons-nous maintenant à notre réducteur : il prend un state et une action (dont nous ne connaissons pas encore le type) et produit un nouvel State :
function reducer( state: State, action: any ): State { ... } Notre boutique propose deux types d'actions différents : UpdatePostAction et ReceiveNewPostAction . Alors, quel est le type d'argument d' action ? L'un ou l'autre, non ? Lorsqu'une variable peut accepter plusieurs types A , B , C , etc., son type est une union de types. Autrement dit, son type peut être A ou B ou C , et ainsi de suite. Un type d'union est un type dont les valeurs peuvent appartenir à n'importe lequel des types spécifiés dans cette union.
Voici comment notre type Action peut être défini comme un type d'union :
type Action = UpdatePostAction | ReceiveNewPostAction; L'extrait de code précédent indique simplement qu'une Action peut être soit une instance du type UpdatePostAction , soit une instance du type ReceiveNewPostAction .
Si nous utilisons maintenant Action dans notre réducteur :
function reducer( state: State, action: Action ): State { ... }nous pouvons voir comment cette nouvelle version de notre base de code, qui est bien typée, fonctionne sans problème.
Comment les types d'union éliminent les cas par défaut
"Attendez une seconde", pourriez-vous dire, "le lien précédent ne fonctionne pas correctement, le compilateur déclenche une erreur !" En effet, selon TypeScript, notre reducer contient du code inaccessible :
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }Attends quoi? Laissez-moi vous expliquer ce qui se passe ici…
Le type d'union Action que nous avons créé est en fait un type d'union discriminé. Un type d'union discriminé est un type d'union dans lequel tous ses types partagent un attribut commun dont la valeur peut être utilisée pour discriminer un type de l'autre.
Dans notre cas, les deux types d' Action ont un attribut de type dont les valeurs sont RECEIVE_NEW_POST pour ReceiveNewPostAction et UPDATE_POST pour UpdatePostAction . Puisque nous savons qu'une Action est, nécessairement, une instance de l'une ou l'autre action, les deux branches de notre switch couvrent toutes les possibilités : soit action.type est RECEIVE_NEW_POST , soit il est UPDATE_POST . Par conséquent, le return final est redondant et sera inaccessible.
Supposons donc que nous supprimions ce return pour corriger cette erreur. Avons-nous gagné quelque chose, au-delà de la suppression du code inutile ? La réponse est oui. Si nous ajoutons maintenant un nouveau type d'action dans notre code :
type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... }; soudainement, l'instruction switch de notre réducteur ne couvrira plus tous les scénarios possibles :
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... // case NEW_FEATURE is missing... } // return undefined is now implicit } Cela signifie que le réducteur peut renvoyer implicitement une valeur undefined si nous l'invoquons à l'aide d'une action de type NEW_FEATURE , et c'est quelque chose qui ne correspond pas à la signature de la fonction. En raison de cette incompatibilité, TypeScript se plaint et nous fait savoir qu'il nous manque une nouvelle branche pour gérer ce nouveau type d'action.
Fonctions polymorphes avec des types de retour variables
Si vous êtes arrivé jusqu'ici, félicitations : vous avez appris tout ce que vous devez faire pour améliorer le code source de vos applications JavaScript à l'aide de TypeScript. Et, en guise de récompense, je vais partager avec vous un "problème" que j'ai rencontré il y a quelques jours et sa solution. Pourquoi? Parce que TypeScript est un monde complexe et fascinant et je veux vous montrer dans quelle mesure cela est vrai.
Au début de toute cette aventure, nous avons vu que l'un des sélecteurs que nous avons est getPostsInDay et comment son type de retour est une liste d'ID de publication :
function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }même si son nom l'indique, il peut renvoyer une liste de messages. Pourquoi ai-je utilisé un nom aussi trompeur, vous vous demandez ? Eh bien, imaginez le scénario suivant : supposons que vous vouliez que cette fonction soit capable de renvoyer une liste d'ID de publication ou de renvoyer une liste de publications réelles, en fonction de la valeur de l'un de ses arguments. Quelque chose comme ça:
const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );Pouvons-nous faire cela en TypeScript ? Bien sûr, nous faisons! Sinon, pourquoi devrais-je soulever ce problème ? Il suffit de définir une fonction polymorphe dont le résultat dépend des paramètres d'entrée.
Donc, l'idée est que nous voulons deux versions différentes de la même fonction. On devrait retourner une liste de PostId si l'un des attributs est la string id . L'autre doit renvoyer une liste de Post si ce même attribut est la string all .
Créons-les tous les deux :
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }Facile, non ? TORT! Cela ne fonctionne pas. Selon TypeScript, nous avons une "implémentation de fonction en double".
Bon, essayons quelque chose de différent, alors. Fusionnons les deux définitions précédentes en une seule fonction :
function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }Cela se comporte-t-il comme nous le souhaitons ? J'ai peur que ce ne soit pas le cas...
Voici ce que nous dit cette signature de fonction : « getPostsInDay est une fonction qui prend deux arguments, un state et un mode dont les valeurs peuvent être id ou all ; son type de retour sera soit une liste de PostId s ou une liste de Post s.” En d'autres termes, la définition de fonction précédente ne spécifie nulle part qu'il existe une relation entre la valeur donnée à l'argument mode et le type de retour de la fonction. Et donc code comme ceci :
const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );est valide et ne se comporte pas comme nous le souhaitons.
Bon, dernière tentative. Et si nous mélangeons notre intuition initiale, où nous décrivons des signatures de fonctions concrètes, avec une seule implémentation valide ?
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[]; function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[]; function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' ): PostId[] | Post[] { const postIds = state.days[ day ] ?? []; if ( 'id' === mode ) { return postIds; } return postIds .map( ( pid ) => getPost( state, pid ) ) .filter( ( p ): p is Post => !! p ); } L'extrait de code précédent a une implémentation de fonction valide qui fonctionne, mais définit deux signatures de fonction supplémentaires qui lient des valeurs concrètes en mode avec le type de retour de la fonction.
En utilisant cette approche, ce code est valide :
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );et celui-ci non :
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );conclusion
Dans cette série d'articles, nous avons vu ce qu'est TypeScript et comment nous pouvons l'appliquer dans nos projets. Les types nous aident à mieux documenter le code en fournissant un contexte sémantique. De plus, les types ajoutent également une couche de sécurité supplémentaire, puisque le compilateur TypeScript se charge de valider que notre code s'emboîte correctement, tout comme le font les Legos.
À ce stade, vous disposez déjà de tous les outils nécessaires pour faire passer la qualité de votre travail au niveau supérieur. Bonne chance dans cette nouvelle aventure !
Image sélectionnée par Mike Kenneally sur Unsplash.
