TypeScript avançado com um exemplo real (Parte 2)
Publicados: 2020-11-20É hora de continuar (e espero terminar) nosso tutorial do TypeScript. Se você perdeu os posts anteriores que escrevemos sobre o TypeScript, aqui estão eles: nossa introdução inicial ao TypeScript e a primeira parte deste tutorial onde explico o exemplo de JavaScript com o qual estamos trabalhando e as etapas que tomamos para melhorá-lo parcialmente .
Hoje vamos terminar nosso exemplo completando tudo o que ainda está faltando. Especificamente, veremos primeiro como criar tipos que são versões parciais de outros tipos existentes. Veremos então como digitar corretamente as ações de uma loja Redux usando uniões de tipo e discutiremos as vantagens que as uniões de tipo oferecem. E, por fim, mostrarei como criar uma função polimórfica cujo tipo de retorno depende de seus argumentos.
Uma breve revisão do que fizemos até agora…
Na primeira parte do tutorial, usamos (parte de) uma loja Redux que pegamos do Nelio Content como nosso exemplo de trabalho. Tudo começou como um código JavaScript simples que precisava ser aprimorado adicionando tipos concretos que o tornavam mais robusto e inteligível. Assim, por exemplo, definimos os seguintes tipos:
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[]>; }; o que nos ajudou a entender, de relance, o tipo de informação com que nossa loja trabalha. Nesta instância em particular, por exemplo, podemos ver que o estado de nossa aplicação armazena duas coisas: uma lista de posts (que indexamos através de seu PostId ) e uma estrutura chamada days que, dado um determinado dia, retorna uma lista de identificadores de postagem. Também podemos ver os atributos (e seus tipos específicos) que encontraremos em um objeto Post .
Uma vez que esses tipos foram definidos, editamos todas as funções do nosso exemplo para usá-los. Essa tarefa simples transformou as assinaturas de funções opacas do JavaScript:
// Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }para assinaturas de função TypeScript autoexplicativas:
// 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 { ... } A função getPostsInDay é um exemplo muito bom de quanto o TypeScript melhorará a qualidade do seu código. Se você olhar para a contrapartida do JavaScript, você realmente não sabe o que essa função vai retornar. Claro, seu nome pode sugerir o tipo de resultado (é uma lista de posts, talvez?), mas você deve olhar para o código-fonte da função (e provavelmente as ações e redutores também) para ter certeza (na verdade é uma lista de IDs de postagem). Pode-se melhorar essa situação nomeando melhor as coisas ( getIdsOfPostsInDay , por exemplo), mas não há nada como tipos concretos para tirar qualquer dúvida: PostId[] .
Então, agora que você está atualizado com o estado atual das coisas, é hora de seguir em frente e corrigir tudo o que ignoramos na semana passada. Especificamente, sabemos que precisamos digitar os atributos attributes da função updatePost e precisamos definir os tipos que nossas ações terão (observe que em reducer , o atributo action agora é do tipo any ).
Como digitar um objeto cujos atributos são um subconjunto de outro objeto
Vamos aquecer começando com algo simples. A função updatePost gera uma ação que sinaliza nossa intenção de atualizar certos atributos de um determinado ID de postagem. Veja como ele se parece:
function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }e veja como a ação é usada pelo redutor para atualizar o post em nossa loja:
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 { ... }; } // ... }Como você pode ver, o redutor pesquisa a postagem na loja e, se estiver lá, atualiza seus atributos sobrescrevendo-os usando os incluídos na ação.
Mas quais são exatamente os attributes de uma ação? Bem, eles são claramente algo que se parece com um Post , pois eles devem substituir os atributos que podemos encontrar em um post:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };mas se tentarmos usar isso, veremos que não funciona:
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', }, }; porque não queremos que os attributes sejam um Post em si; queremos que seja um subconjunto de atributos Post (ou seja, queremos especificar apenas os atributos de um objeto Post que iremos sobrescrever).
Para resolver este problema, basta usar o tipo de utilitário Partial :
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };E é isso! Ou é?
Filtrando atributos explicitamente
O trecho anterior ainda está com defeito, pois é possível obter alguns erros de tempo de execução que o compilador do TypeScript não está verificando. Aqui está o porquê: a ação que sinaliza uma atualização de postagem tem dois argumentos, um ID de postagem e o conjunto de atributos que queremos atualizar. Assim que tivermos a ação pronta, o redutor se encarrega de sobrescrever o post existente com os novos valores:
const post = { ...state.posts[ action.postId ], ...action.attributes, }; E essa é precisamente a parte defeituosa em nosso código; é possível que o atributo postId da ação tenha um ID de potes x e o atributo id nos attributes tenha um ID de postagem diferente y :
const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, }; Obviamente, essa é uma ação válida e, portanto, o TypeScript não aciona nenhum erro, mas sabemos que não deveria ser. O atributo id nos attributes (se presente) e o atributo postId devem ter o mesmo valor, ou então teremos uma ação incoerente. Nosso tipo de ação é impreciso porque nos permite definir uma situação que deveria ser impossível... então como podemos corrigir isso? Muito facilmente: basta alterar esse tipo para que esse cenário que deveria ser impossível se torne realmente impossível.
A primeira solução que pensei é a seguinte: remova o atributo postId da ação e adicione o ID no attributes attribute:
type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; } Em seguida, atualize seu redutor para que ele use action.attributes.id em vez de action.postId para localizar e substituir a postagem existente.
Infelizmente, essa solução não é a ideal, pois attributes é um “post parcial”, lembra? Isso significa que, em teoria, o atributo id pode ou não estar no objeto de attributes . Claro, sabemos que estará lá, porque somos nós que geramos a ação... mas nossos tipos ainda são imprecisos. Se no futuro alguém modificar a função updatePost e não garantir que os attributes incluam o postId , a ação resultante seria válida de acordo com o TypeScript, mas nosso código não funcionaria:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Portanto, se quisermos que o TypeScript nos proteja, devemos ser o mais precisos possível ao especificar os tipos e garantir que eles tornem impossíveis estados impossíveis. Considerando tudo isso, temos apenas duas opções disponíveis:
- Se tivermos um atributo
postIdem ação (como fizemos no início), então o objetoattributesnão deve conter um atributoid. - Se, por outro lado, a ação não tiver um atributo
postId, osattributesdeverão conter um atributoid.
A primeira solução pode ser facilmente especificada usando outro tipo de utilitário, Omit , que nos permite criar um novo tipo removendo atributos de um tipo existente:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };que funciona como esperado:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, }; Para a segunda opção, temos que adicionar explicitamente o atributo id no topo do tipo Partial<Post> que definimos:

type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };que, novamente, nos dá o resultado esperado:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Tipos de União
Na seção anterior, já vimos como digitar uma das duas ações que nossa loja possui. Vamos fazer o mesmo com a segunda ação. Sabendo que receiveNewPost se parece com isso:
function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }seu tipo pode ser definido da seguinte forma:
type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };Fácil, certo?
Agora vamos dar uma olhada no nosso redutor: ele pega um state e uma action (cujo tipo ainda não conhecemos) e produz um novo State :
function reducer( state: State, action: any ): State { ... } Nossa loja possui dois tipos diferentes de ações: UpdatePostAction e ReceiveNewPostAction . Então, qual é o tipo de argumento de action ? Um ou outro, certo? Quando uma variável pode aceitar mais de um tipo A , B , C e assim por diante, seu tipo é uma união de tipos. Ou seja, seu tipo pode ser A ou B ou C e assim por diante. Um tipo de união é um tipo cujos valores podem ser de qualquer um dos tipos especificados nessa união.
Veja como nosso tipo de Action pode ser definido como um tipo de união:
type Action = UpdatePostAction | ReceiveNewPostAction; O trecho anterior está simplesmente informando que uma Action pode ser uma instância do tipo UpdatePostAction ou uma instância do tipo ReceiveNewPostAction .
Se agora usarmos Action em nosso redutor:
function reducer( state: State, action: Action ): State { ... }podemos ver como esta nova versão do nosso código, que é bem tipado, funciona sem problemas.
Como os tipos de união eliminam casos padrão
“Espere um segundo”, você pode dizer, “o link anterior não está funcionando bem, o compilador está acionando um erro!” De fato, de acordo com o TypeScript, nosso redutor contém código inacessível:
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }Espere o que? Deixe-me explicar o que está acontecendo aqui...
O tipo de união Action que criamos é na verdade um tipo de união discriminado. Um tipo de união discriminado é um tipo de união em que todos os seus tipos compartilham um atributo comum cujo valor pode ser usado para discriminar um tipo do outro.
No nosso caso, os dois tipos Action possuem um atributo type cujos valores são RECEIVE_NEW_POST para ReceiveNewPostAction e UPDATE_POST para UpdatePostAction . Como sabemos que uma Action é, necessariamente, uma instância de uma ou outra ação, os dois ramos do nosso switch cobrem todas as possibilidades: ou action.type é RECEIVE_NEW_POST ou é UPDATE_POST . Portanto, o return final é redundante e inalcançável.
Suponha, então, que removamos esse return para corrigir esse erro. Ganhamos alguma coisa, além de remover código desnecessário? A resposta é sim. Se agora adicionarmos um novo tipo de ação em nosso código:
type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... }; de repente, a instrução switch em nosso redutor não cobre mais todos os cenários possíveis:
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 } Isso significa que o redutor pode retornar implicitamente um valor undefined se o invocarmos usando uma ação do tipo NEW_FEATURE , e isso é algo que não corresponde à assinatura da função. Por causa dessa incompatibilidade, o TypeScript reclama e nos informa que está faltando uma nova ramificação para lidar com esse novo tipo de ação.
Funções polimórficas com tipos de retorno variável
Se você chegou até aqui, parabéns: você aprendeu tudo o que precisa fazer para melhorar o código-fonte de seus aplicativos JavaScript usando TypeScript. E, como recompensa, vou compartilhar com vocês um “problema” que me deparei há alguns dias e sua solução. Por quê? Porque o TypeScript é um mundo complexo e fascinante e quero mostrar até que ponto isso é verdade.
No início de toda essa aventura, vimos que um dos seletores que temos é getPostsInDay e como seu tipo de retorno é uma lista de IDs de postagem:
function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }mesmo que o nome sugira que pode retornar uma lista de postagens. Por que eu usei um nome tão enganoso, você está se perguntando? Bem, imagine o seguinte cenário: suponha que você queira que essa função seja capaz de retornar uma lista de IDs de postagem ou retornar uma lista de postagens reais, dependendo do valor de um de seus argumentos. Algo assim:
const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );Podemos fazer isso no TypeScript? Claro que nós fazemos! Por que mais eu iria trazer isso à tona de outra forma? Tudo o que temos a fazer é definir uma função polimórfica cujo resultado depende dos parâmetros de entrada.
Então, a ideia é que queremos duas versões diferentes da mesma função. Deve-se retornar uma lista de PostId s se um dos atributos for a string id . O outro deve retornar uma lista de Post s se esse mesmo atributo for a string all .
Vamos criar os dois:
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }Fácil, certo? ERRADO! Isso não funciona. De acordo com o TypeScript, temos uma “implementação de função duplicada”.
Ok, vamos tentar algo diferente, então. Vamos mesclar as duas definições anteriores em uma única função:
function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }Isso se comporta como queremos? temo que não…
Aqui está o que esta assinatura de função está nos dizendo: “ getPostsInDay é uma função que recebe dois argumentos, um state e um mode cujos valores podem ser id ou all ; seu tipo de retorno será uma lista de PostId ou uma lista de Post .” Em outras palavras, a definição de função anterior não especifica em nenhum lugar que haja uma relação entre o valor dado ao argumento de mode e o tipo de retorno da função. E assim codifique assim:
const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );é válido e não se comporta como queremos.
Certo, última tentativa. E se misturarmos nossa intuição inicial, onde descrevemos assinaturas de funções concretas, com uma implementação única e válida?
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 ); } O trecho anterior tem uma implementação de função válida que funciona, mas define duas assinaturas de função extras que vinculam valores concretos no mode com o tipo de retorno da função.
Usando essa abordagem, este código é válido:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );e este não:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );Conclusões
Nesta série de posts vimos o que é o TypeScript e como podemos aplicá-lo em nossos projetos. Os tipos nos ajudam a documentar melhor o código fornecendo contexto semântico. Além disso, os tipos também adicionam uma camada extra de segurança, já que o compilador TypeScript se encarrega de validar se nosso código se encaixa corretamente, assim como os Legos.
Neste ponto você já tem todas as ferramentas necessárias para levar a qualidade do seu trabalho para o próximo nível. Boa sorte nesta nova aventura!
Imagem em destaque por Mike Kenneally no Unsplash.
