Ajout de TypeScript à @wordpress/magasins de données

Publié: 2021-02-05

L'année dernière, nous avons beaucoup parlé de TypeScript. Dans un de mes derniers articles nous avons vu comment utiliser TypeScript dans vos plugins WordPress à travers un exemple réel et, notamment, comment améliorer une boutique Redux en ajoutant des types à nos sélecteurs, actions et réducteurs.

Dans cet exemple, nous sommes passés d'un code JavaScript de base comme celui-ci :

 // Selectors function getPost( state, id ) { … } function getPostsInDay( state, day ) { … } // Actions function receiveNewPost( post ) { … } function updatePost( postId, attributes ) { … } // Reducer function reducer( state, action ) { … }

où la seule chose qui nous a donné des indices sur ce que fait chaque fonction et ce qu'est chaque paramètre dépend de nos capacités de dénomination, à l'homologue TypeScript amélioré suivant :

 // Selectors function getPost( state: State, id: PostId ): Post | undefined { … } function getPostsInDay( state: State, day: Day ): PostId[] { … } // Actions function receiveNewPost( post: Post ): ReceiveNewPostAction { … } function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { … } // Reducer function reducer( state: State, action: Action ): State { … }

ce qui rend tout beaucoup plus clair, car tout est correctement tapé :

 type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary; days: Dictionary; };

Il y a quelques semaines, je travaillais sur notre nouveau plugin, Nelio Unlocker, et j'ai rencontré un problème lors de l'application de toutes ces techniques. Passons donc en revue ledit problème et apprenons à le surmonter !

Le problème

Comme vous le savez peut-être déjà, lorsque nous voulons utiliser les sélecteurs et/ou les actions que nous avons définis dans notre boutique, nous le faisons en y accédant via des crochets React (avec useSelect et useDispatch ) ou via des composants d'ordre supérieur (avec withSelect et withDispatch ) , qui sont tous fournis par le package @wordpress/data .

Par exemple, si nous voulions utiliser le sélecteur getPost et l'action updatePost que nous venons de voir, tout ce que nous avons à faire est quelque chose comme ceci (en supposant que notre magasin s'appelle nelio-store ):

 const Component = ( { postId } ): JSX.Element => { const post = useSelect( ( select ): Post => select( 'nelio-store' ).getPost( postId ); ); const { updatePost } = useDispatch( 'nelio-store' ); return ( ... ); };

Dans l'extrait précédent, vous pouvez voir que nous accédons à nos sélecteurs et actions à l'aide de crochets React. Mais comment diable TypeScript sait-il que ces sélecteurs et actions existent, sans parler de leurs types ?

Eh bien, c'est exactement le problème que j'ai rencontré. Autrement dit, je voulais savoir comment je pouvais dire à TypeScript que le résultat de l'accès à select('nelio-store') est un objet qui contient tous nos sélecteurs de magasin et dispatch('nelio-store') est un objet avec nos actions de magasin .

La solution

Dans notre dernier article sur TypeScript, nous avons parlé des fonctions polymorphes. Les fonctions polymorphes nous permettent de spécifier différents types de retour en fonction des arguments donnés. Eh bien, en utilisant le polymorphisme TypeScript, nous pouvons spécifier que, lorsque nous appelons les méthodes select ou dispatch du package @wordpress/data avec le nom de notre magasin comme paramètre, le résultat que nous obtenons est respectivement nos sélecteurs et nos actions.

Pour ce faire, ajoutez simplement un bloc de declare module dans le fichier où nous enregistrons notre magasin comme suit :

 // WordPress dependencies import { registerStore } from '@wordpress/data'; import { controls } from '@wordpress/data-controls'; // Internal dependencies import reducer from './reducer'; import * as actions from './actions'; import * as selectors from './selectors'; const STORE = 'nelio-store'; registerStore( STORE, { controls, reducer, actions, selectors, } ); // Extend @wordpress/data with our store declare module '@wordpress/data' { function select( key: typeof STORE ): Selectors; function dispatch( key: typeof STORE ): Actions; }

puis définissez ce que sont réellement les types de Selectors et d' Actions :

 type Selectors = { getPost: ( id: PostId ) => Post | undefined; getPostsInDay: ( day: Day ) => PostId[]; } type Actions = { receiveNewPost: ( post: Post ) => void; updatePost: ( postId: PostId, attributes: Partial<Post> ) => void; }

Jusqu'ici, tout va bien, non ? Le seul "problème" est que nous devons définir manuellement les types de Selectors et d' Actions , ce qui semble bizarre étant donné que TypeScript sait déjà que nous avons un ensemble de selectors et d' actions correctement typés...

Manipulation des types de fonctions dans TypeScript

Si nous examinons les types d'objets d' actions et de selectors que nous avons importés, nous verrons que TypeScript nous dit ce qui suit :

 typeof selectors === { getPost: ( state: State, id: PostId ) => Post | undefined; getPostsInDay: ( state: State, day: Day ) => PostId[]; } typeof actions === { receiveNewPost: ( post: Post ) => ReceiveNewPostAction; updatePost: ( postId: PostId, attributes: Partial<Post> ) => UpdatePostAction; }

Comme vous pouvez le voir, leurs types sont une copie exacte des types que nous avons définis manuellement dans la section précédente. Eh bien, presque exact : il manque aux sélecteurs leur premier argument (le store state , car il n'est pas présent lorsque nous appelons un sélecteur depuis select ) et les actions renvoient void (car les actions appelées via dispatch ne renvoient rien).

Pouvons-nous les utiliser pour générer automatiquement les types de Selectors et d' Actions dont nous avons besoin ?

Comment supprimer le premier paramètre d'un type de fonction dans TypeScript

Concentrons-nous un instant sur le sélecteur getPost . Son type est le suivant :

 // Old type typeof getPost === ( state: State, id: PostId ) => Post | undefined

Comme nous venons de le dire, nous avons besoin d'un nouveau type de fonction qui n'a pas le paramètre state :

 // New type ( id: PostId ) => Post | undefined

Nous avons donc besoin de TypeScript pour générer un nouveau type à partir d'un type déjà existant. Ceci peut être réalisé en combinant plusieurs fonctionnalités avancées du langage :

 type OmitFirstArg< F > = F extends ( x: any, ...args: infer P ) => infer R ? ( ...args: P ) => R : never;

Compliqué, hein ? Regardons de plus près ce qui se passe ici :

  • type OmitFirstArg<F> . Tout d'abord, nous définissons un nouveau type générique auxiliaire ( OmitFirstArg ) . En général, un type générique est un type qui nous permet de définir de nouveaux types à partir de types déjà existants. Par exemple, vous êtes probablement familier avec le type Array<T> , car il vous permet de créer des listes de choses : Array<string> est une liste de chaînes, Array<Post> est une liste de Post , etc. cette notion, OmitFirstArg<F> est un type d'assistance qui supprime le premier argument d'une fonction.
  • Comme il s'agit d'un type générique, nous pourrions théoriquement l'utiliser avec n'importe quel autre type TypeScript. Autrement dit, des choses comme OmitFirstArg<string> et OmitFirstArg<Post> sont possibles… même si nous savons que ce type ne doit être utilisé qu'avec des fonctions qui ont au moins un argument. Afin de nous assurer que ce type d'assistance est utilisé uniquement avec des fonctions, nous le définirons comme un type conditionnel. Le type conditionnel spécifions ce que le type résultant doit être basé sur une condition : "si F est une fonction avec au moins un argument (condition), le type résultant est une autre fonction où le premier argument a été supprimé (type lorsque la condition est vrai); sinon, utilisez le type never (type lorsque la condition est fausse).
  • F extends XXX . C'est la formule pour spécifier la condition. Voulez-vous vérifier que F est une chaîne ? Tapez simplement : F extends string . Peasy facile. Mais qu'en est-il de "une fonction avec un argument?" Cela semble certainement plus compliqué…
  • (x: any, ...args: infer P) => infer R . Il s'agit d'un type de fonction : nous commençons par les arguments (entre parenthèses), suivis d'une flèche, suivis du type de retour de la fonction. Dans ce cas particulier, nous exigeons que la fonction ait un argument x (dont le type spécifique n'est pas pertinent). Cette définition de type a deux bits intéressants. D'une part, nous utilisons l'opérateur rest pour capturer les types P des args restants (le cas échéant). D'autre part, nous utilisons l'inférence de type de TypeScript ( infer ) pour savoir ce que sont réellement ces types P , ainsi que le type de retour exact R .
  • ? (...args: P) => R : never ? (...args: P) => R : never . Enfin, nous complétons le type conditionnel. Si F était une fonction, le type de retour est une nouvelle fonction dont les arguments sont de type P et dont le type de retour est R . Si ce n'est pas le cas, le type de retour est never .

Voici comment nous pouvons utiliser ce type d'assistance pour créer le nouveau type que nous voulions :

 const getPost = ( state: State, id: PostId ) => Post | undefined; OmitFirstArg< typeof getPost > === ( id: PostId ) => Post | undefined;

et nous sommes déjà un pas de plus vers la réalisation de ce que nous voulons ! Ici vous pouvez voir cet exemple dans la cour de récréation.

Comment changer le type de retour d'un type de fonction dans TypeScript

Je suis sûr que vous connaissez déjà la réponse : nous avons besoin d'un type générique auxiliaire qui prend un type de fonction et renvoie un nouveau type de fonction. Quelque chose comme ça:

 type RemoveReturnType< F > = F extends ( ...args: infer P ) => any ? ( ...args: P ) => void : never;

Facile, non ? C'est assez similaire à ce que nous avons fait dans la section précédente : nous capturons les types des args dans P (il n'est pas nécessaire d'exiger au moins un argument x cette fois) et ignorons le type de retour. Si F est une fonction, renvoie une nouvelle fonction qui renvoie void . Sinon, ne revenez never . Impressionnant!

Vérifiez cela dans la cour de récréation.

Comment mapper un type d'objet à un autre type d'objet dans TypeScript

Nos actions et nos sélecteurs sont deux objets dont les clés sont les noms de ces actions et sélecteurs et dont les valeurs sont les fonctions elles-mêmes. Cela signifie que les types de ces objets ressemblent à ce qui suit :

 typeof selectors === { getPost: ( state: State, id: PostId ) => Post | undefined; getPostsInDay: ( state: State, day: Day ) => PostId[]; } typeof actions === { receiveNewPost: ( post: Post ) => ReceiveNewPostAction; updatePost: ( postId: PostId, attributes: Partial<Post> ) => UpdatePostAction; }

Dans les deux sections précédentes, nous avons appris à transformer un type de fonction en un autre type de fonction. Cela signifie que nous pourrions définir de nouveaux types à la main comme ceci :

 type Selectors = { getPost: OmitFirstArg< typeof selectors.getPost >; getPostsInDay: OmitFirstArg< typeof selectors.getPostsInDay >; }; type Actions = { receiveNewPost: RemoveReturnType< actions.receiveNewPost >; updatePost: RemoveReturnType< actions.updatePost >; };

Mais, bien sûr, ce n'est pas durable dans le temps : nous spécifions manuellement les noms des fonctions dans les deux types. De toute évidence, nous voulons mapper automatiquement les définitions de type d'origine des actions et des selectors vers de nouveaux types.

Voici comment vous pouvez le faire dans TypeScript :

 type OmitFirstArgs< O > = { [ K in keyof O ]: OmitFirstArg< O[ K ] >; } type RemoveReturnTypes< O > = { [ K in keyof O ]: RemoveReturnType< O[ K ] >; }

J'espère que cela a déjà du sens, mais voyons rapidement ce que fait l'extrait précédent de toute façon :

  • type OmitFirstArgs<O> . Nous créons un nouveau type générique auxiliaire qui prend un objet O .
  • Le résultat est un autre type d'objet (comme les accolades révèlent {...} ).
  • [K in keyof O] . Nous ne connaissons pas les clés exactes que le nouvel objet aura, mais nous savons qu'il doit s'agir des mêmes clés que celles incluses dans O . C'est donc ce que nous disons à TypeScript : nous voulons toutes les clés K qui sont une keyof O .
  • Et puis, pour chaque clé K , son type est OmitFirstArg<O[K]> . Autrement dit, nous obtenons le type d'origine ( O[K] ) et nous le transformons en le type que nous voulons en utilisant le type auxiliaire que nous avons défini (dans ce cas, OmitFirstArg ).
  • Enfin, nous faisons de même avec RemoveReturnTypes et le type auxiliaire d'origine RemoveReturnType .

Extension @wordpress/data avec nos sélecteurs et actions

Si vous ajoutez les quatre types auxiliaires que nous avons vus aujourd'hui dans un fichier global.d.ts et que vous l'enregistrez à la racine de votre projet, vous pouvez enfin combiner tout ce que nous avons vu dans ce post pour résoudre le problème initial :

 // WordPress dependencies import { registerStore } from '@wordpress/data'; import { controls } from '@wordpress/data-controls'; // Internal dependencies import reducer from './reducer'; import * as actions from './actions'; import * as selectors from './selectors'; // Types type Selectors = OmitFirstArgs< typeof selectors >; type Actions = RemoveReturnTypes< typeof actions >; const STORE = 'nelio-store'; registerStore( STORE, { controls, reducer, actions, selectors, } ); // Extend @wordpress/data with our store declare module '@wordpress/data' { function select( key: typeof STORE ): Selectors; function dispatch( key: typeof STORE ): Actions; }

Et c'est tout! J'espère que vous avez aimé ce conseil de développement et, si tel est le cas, partagez-le avec vos collègues et amis. Oh! Et si vous connaissez une approche différente pour obtenir le même résultat, dites-le moi dans les commentaires.

Image sélectionnée par Gabriel Crismariu sur Unsplash.