Adicionando TypeScript a @wordpress/data Stores

Publicados: 2021-02-05

Ano passado conversamos muito sobre TypeScript. Em um dos meus últimos posts vimos como usar o TypeScript em seus plugins WordPress através de um exemplo real e, em particular, como melhorar uma loja Redux adicionando tipos aos nossos seletores, ações e redutores.

No referido exemplo, partimos do código JavaScript básico assim:

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

onde a única coisa que nos deu pistas sobre o que cada função faz e o que cada parâmetro é depende de nossas habilidades de nomenclatura, para a seguinte contraparte aprimorada do TypeScript:

 // 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 { … }

o que torna tudo muito mais claro, pois tudo está devidamente digitado:

 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; };

Algumas semanas atrás eu estava trabalhando em nosso novo plugin, Nelio Unlocker, e me deparei com um problema ao aplicar todas essas técnicas. Então vamos rever o referido problema e aprender como superá-lo!

O problema

Como você já deve saber, quando queremos usar os seletores e/ou ações que definimos em nossa loja, fazemos isso acessando-os através de hooks React (com useSelect e useDispatch ) ou via componentes de ordem superior (com withSelect e withDispatch ) , todos fornecidos pelo pacote @wordpress/data .

Por exemplo, se quisermos usar o seletor getPost e a ação updatePost que acabamos de ver, tudo o que precisamos fazer é algo assim (supondo que nossa loja seja chamada nelio-store ):

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

No trecho anterior, você pode ver que acessamos nossos seletores e ações usando ganchos do React. Mas como diabos o TypeScript sabe que esses seletores e ações existem, sem falar quais são seus tipos?

Bem, esse é exatamente o problema que enfrentei. Ou seja, eu queria saber como eu poderia dizer ao TypeScript que o resultado de acessar select('nelio-store') é um objeto que contém todos os nossos seletores de loja e dispatch('nelio-store') é um objeto com nossas ações de armazenamento .

A solução

Em nosso último post no TypeScript falamos sobre funções polimórficas. As funções polimórficas nos permitem especificar diferentes tipos de retorno com base nos argumentos fornecidos. Bem, usando o polimorfismo TypeScript podemos especificar que, quando chamamos os métodos select ou dispatch do pacote @wordpress/data com o nome da nossa loja como parâmetro, o resultado que obtemos são nossos seletores e nossas ações respectivamente.

Para isso, basta adicionar um bloco de declare module no arquivo onde registramos nossa loja da seguinte forma:

 // 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; }

e, em seguida, defina quais são os tipos Selectors e 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; }

Até aí, tudo bem, certo? O único “problema” é que temos que definir manualmente os tipos Selectors e Actions , o que soa estranho já que o TypeScript já sabe que temos um conjunto de selectors e actions devidamente tipados…

Manipulando tipos de função no TypeScript

Se dermos uma olhada nos tipos de objetos de actions e selectors que importamos, veremos que o TypeScript nos diz o seguinte:

 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; }

Como você pode ver, seus tipos são uma cópia exata dos tipos que definimos manualmente na seção anterior. Bem, quase exato: os seletores estão faltando seu primeiro argumento (o store state , porque ele não está presente quando chamamos um seletor de select ) e as ações retornam void (já que as ações chamadas via dispatch não retornam nada).

Podemos usá-los para gerar automaticamente os tipos de Selectors e Actions que precisamos?

Como remover o primeiro parâmetro de um tipo de função no TypeScript

Vamos nos concentrar por um momento no seletor getPost . Seu tipo é o seguinte:

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

Como acabamos de dizer, precisamos de um novo tipo de função que não tenha o parâmetro state :

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

Portanto, precisamos do TypeScript para gerar um novo tipo a partir de um tipo já existente. Isso pode ser alcançado combinando várias funcionalidades avançadas da linguagem:

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

Complicado, né? Vamos dar uma olhada mais de perto no que está acontecendo aqui:

  • type OmitFirstArg<F> . Em primeiro lugar, definimos um novo tipo genérico auxiliar ( OmitFirstArg ) . Em geral, um tipo genérico é um tipo que nos permite definir novos tipos a partir de tipos já existentes. Por exemplo, você provavelmente está familiarizado com o tipo Array<T> , pois ele permite criar listas de coisas: Array<string> é uma lista de strings, Array<Post> é uma lista de Post , etc. Bem, seguindo essa noção, OmitFirstArg<F> é um tipo auxiliar que remove o primeiro argumento de uma função.
  • Como esse é um tipo genérico, teoricamente poderíamos usá-lo com qualquer outro tipo TypeScript. Ou seja, coisas como OmitFirstArg<string> e OmitFirstArg<Post> são possíveis... mesmo sabendo que esse tipo só deve ser usado com funções que tenham pelo menos um argumento. Para garantir que esse tipo de auxiliar seja usado apenas com funções, vamos defini-lo como um tipo condicional. O tipo condicional nos permite especificar qual deve ser o tipo resultante baseado em uma condição: “se F é uma função com pelo menos um argumento (condição), o tipo resultante é outra função onde o primeiro argumento foi removido (tipo quando a condição é verdadeiro); caso contrário, use o tipo never (tipo quando a condição for falsa).”
  • F extends XXX . Esta é a fórmula para especificar a condição. Deseja verificar se F é uma string? Basta digitar: F extends string . Mole-mole. Mas e quanto a “uma função com um argumento?” Isso com certeza parece mais complicado…
  • (x: any, ...args: infer P) => infer R . Este é um tipo de função: começamos com os argumentos (entre parênteses), seguidos por uma seta, seguido pelo tipo de retorno da função. Nesse caso específico, exigimos que a função tenha um argumento x (cujo tipo específico é irrelevante). Esta definição de tipo tem dois bits interessantes. Por um lado, usamos o operador rest para capturar os tipos P dos args restantes (se houver). Por outro lado, usamos a inferência de tipos do TypeScript ( infer ) para saber o que realmente são esses tipos P , bem como o tipo de retorno exato R .
  • ? (...args: P) => R : never ? (...args: P) => R : never . Por fim, completamos o tipo condicional. Se F era uma função, o tipo de retorno é uma nova função cujos argumentos são do tipo P e cujo tipo de retorno é R . Se não for, o tipo de retorno never será .

É assim que podemos usar esse tipo auxiliar para criar o novo tipo que queríamos:

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

e já estamos um passo mais perto de alcançar o que queremos! Aqui você pode ver este exemplo no playground.

Como alterar o tipo de retorno de um tipo de função no TypeScript

Tenho certeza que você já sabe a resposta por saber: precisamos de um tipo genérico auxiliar que receba um tipo de função e retorne um novo tipo de função. Algo assim:

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

Fácil, certo? É bem parecido com o que fizemos na seção anterior: capturamos os tipos dos args em P (não há necessidade de exigir pelo menos um argumento x desta vez) e ignoramos o tipo de retorno. Se F for uma função, retorne uma nova função que retorne void . Caso contrário, never retorne. Impressionante!

Confira isso no parquinho.

Como mapear um tipo de objeto para outro tipo de objeto no TypeScript

Nossas ações e nossos seletores são dois objetos cujas chaves são os nomes dessas ações e seletores e cujos valores são as próprias funções. Isso significa que os tipos desses objetos se parecem com o seguinte:

 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; }

Nas duas seções anteriores, aprendemos como transformar um tipo de função em outro tipo de função. Isso significa que poderíamos definir novos tipos manualmente assim:

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

Mas, é claro, isso não é sustentável ao longo do tempo: estamos especificando manualmente os nomes das funções em ambos os tipos. Claramente, queremos mapear automaticamente as definições de tipo original de actions e selectors para novos tipos.

Veja como você pode fazer isso no TypeScript:

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

Felizmente, isso já faz sentido, mas vamos desenrolar rapidamente o que o trecho anterior faz de qualquer maneira:

  • type OmitFirstArgs<O> . Criamos um novo tipo genérico auxiliar que recebe um objeto O .
  • O resultado é outro tipo de objeto (como as chaves revelam {...} ).
  • [K in keyof O] . Não sabemos as chaves exatas que o novo objeto terá, mas sabemos que elas devem ser as mesmas chaves incluídas em O . Então é isso que dizemos ao TypeScript: queremos todas as chaves K que são uma chave de keyof O .
  • E então, para cada chave K , seu tipo é OmitFirstArg<O[K]> . Ou seja, obtemos o tipo original ( O[K] ) e o transformamos no tipo que queremos usando o tipo auxiliar que definimos (neste caso, OmitFirstArg ).
  • Por fim, fazemos o mesmo com RemoveReturnTypes e o tipo auxiliar original RemoveReturnType .

Estendendo @wordpress/data com nossos seletores e ações

Se você adicionar os quatro tipos auxiliares que vimos hoje em um arquivo global.d.ts e salvá-lo na raiz do seu projeto, você pode finalmente combinar tudo o que vimos neste post para resolver o problema original:

 // 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; }

E é isso! Espero que você tenha gostado desta dica de desenvolvimento e, se gostou, compartilhe com seus colegas e amigos. Oh! E se você conhece uma abordagem diferente para obter o mesmo resultado, me conte nos comentários.

Imagem em destaque por Gabriel Crismariu no Unsplash.