Digitar Yoga: digitando funções flexíveis com os recursos avançados do TypeScript

James McNamara Segue 17 de jul · 12 min ler

Alguns meses atrás eu decidi adicionar ligações TypeScript à minha biblioteca, sombras . Se você não estiver familiarizado, o Shades fornece uma maneira rápida e declarativa de descrever os caminhos através de um objeto, que pode ser usado para extrair seus valores ou até mesmo modificá-lo imensamente (criando um novo objeto com o valor alterado). Se você quer aprender mais sobre esses bad boys (e eu acho que deveria), você pode assistir a minha palestra no Reactathon.

Mas como digitamos funções que são tão flexíveis? Por exemplo, obtenha pode ter um número variável de diferentes entradas representando caminhos em um objeto e ainda inferir o tipo de saída no final desse caminho:

Magia

Acima, get está criando uma função que pega um objeto User , extrai seus friends propriedade, filtra apenas os usuários que possuem goldMember status de true e, a partir dessa lista, passa e seleciona cada um dos nomes dos membros gold. Qual é o resultado final disso? Uma string[] .

Além do mais, get criou a função que fiz tudo isso antes que um usuário foi passado. Isso significa que ele não sabia que ele ia ser um usuário de verificação de tipo. Na verdade, qualquer objeto que tivesse uma propriedade de friends que fosse uma lista de objetos com um goldMember e name A propriedade funcionaria com a nova função getter e produziria uma lista de qualquer name estivesse naquele novo objeto.

Então, como podemos dar um tipo significativo para algo que é tão genérico? Bem, se olharmos para o tipo desse novo getter, é um pouco intimidante.

Wowzers

Fique comigo por alguns minutos, e nós vamos construir uma versão completa deste tipo de função, com todos os sinos e assobios. E não se preocupe se você nunca usou máscaras , nós vamos construir tudo do zero, e vamos facilitar essa piscina, para que não percamos ninguém. Tudo o que você precisa é de uma familiaridade com o TypeScript e alguns de seus recursos.

Passos de bebê

Começaremos criando uma versão simplificada do get . Isso get somente strings representando chaves e produz uma nova função que pode pegar algum objeto e extrair esse caminho do objeto.

Antes de começarmos a digitar isso, criaremos um tipo de ajuda chamado HasKey para que as coisas não fiquem muito confusas.

HasKey é um tipo mapeado . Ele representa um objeto que tem uma determinada string K mapeada para o valor SOME. Note que leva um segundo parâmetro opcional V que nos permite especificar o tipo em K , mas o padrão é any .

Então, se quisermos definir um tipo HasName que tenha um name prop mapeado para uma string, poderíamos fazer isso com:

HasKey é um tipo muito geral que não parece particularmente útil no começo. O truque é que podemos usá-lo como uma restrição em nossa função para garantir que nossa entrada tenha as chaves de que precisamos. Armado com isso, estamos prontos para escrever nosso primeiro get função.

V1: Strings All The Way Down

A função acima pega uma string K e produz uma nova função que aceita qualquer objeto , desde que esse objeto tenha K como uma chave. Essa é a magia do HasKey ; Podemos usá-lo como parte de uma cláusula de extends para garantir que qualquer coisa que recebamos tenha a chave que queremos. Então o tipo de resultado é o tipo da chave K em S

Também podemos empilhar isso juntos para obter acessores aninhados:

Bonecas de aninhamento

Observe como estamos agora usando esse segundo parâmetro opcional do HasKey ? Estamos especificando que nossa entrada S deve ser um objeto com alguma chave K1 que em si é um objeto com alguma chave K2 . Poderíamos continuar repetindo esse processo para garantir qualquer número de chaves e qualquer caminho de profundidade.

S [O que agora?]

Esse S[K] no tipo de retorno pode não ser familiar. É chamado de tipo de índice . É um recurso embutido do TypeScript que nos permite genericamente referenciar o tipo em uma chave de um objeto quando essa chave não é conhecida antes do tempo. Embora funcione muito bem para o nosso caso acima, a má notícia é que ele não será capaz de lidar quando começarmos a misturar mais caminhos abstratos, como travessias e lentes virtuais, em nossos getters.

A boa notícia é que o TypeScript nos fornece as ferramentas de que precisamos para criar nossos próprios tipos de índices que funcionarão com qualquer combinação de valores que precisarmos. A bala de prata é tipos condicionais .

Os tipos condicionais são como uma declaração if para tipos. Na verdade, eles são exatamente isso. Eles permitem que você faça uma pergunta sobre um tipo e, dependendo da resposta, retorne um tipo diferente. Podemos usar isso para criar nosso próprio tipo de índice chamado KeyAt.

KeyAt pega um objeto e uma string e se esse objeto tiver essa string como uma chave, ele retornará o tipo nessa chave. Se não, apenas retorna nunca , um tipo embutido que (como o nome indica) nunca pode existir. O fato de que o KeyAt ainda pode fazer alguma coisa quando a chave está faltando nos permitirá usá-la nos casos em que sabemos que um objeto terá as chaves certas, mas o TS não pode prová-lo sem uma pequena ajuda. Esta será a chave para escrever funções get mais complexas.

KeyAt age como uma função para tipos

V2: KeyAt eles

Esta versão é quase idêntica, exceto que ela usa o mesmo tipo de truque de aninhamento que usamos para o HasKey com o novo KeyAt . Observe que o aninhamento acontece na direção oposta: perguntamos qual objeto é KeyAt<S, K1> e a partir desse resultado extrairemos K2 .

Agora, criamos uma função que usa uma ou duas cadeias de caracteres e produz uma função de acesso que pode pegar qualquer objeto com as chaves fornecidas e extrair o tipo correto a partir dele. Já criamos algo bastante flexível e real, e merecemos dar um tapinha nas costas.

Agora chegamos ao ponto de realmente começarmos a cozinhar com gás.

Traversais

Traversals representam uma maneira de filtrar uma coleção de objetos que encontramos em nosso caminho e continuam extraindo valores de objetos individuais na coleção, reunindo os resultados em uma coleção na saída. Por exemplo, em nossa introdução, a matching foi uma passagem que filtrou a lista de friends para apenas os usuários que tinham status de membro de ouro e, em seguida, conseguimos extrair todos os nomes deles em uma lista. Esse tipo de comportamento é uma das tonalidades mais atraentes, então definitivamente queremos ver como digitá-lo.

O que é uma travessia ? Bem, é apenas um Traversal

Traversal descreve a forma de um objeto Traversal. O mais estranho é que na verdade não precisamos que contenha nada. Ele só vai agir como um marcador, um sinal para get que esta posição no caminho seja uma coleção de Item . Por causa disso, poderemos usar o mesmo objeto para manipular qualquer tipo de coleção (como coleções de Array , Object , ES2015 Map e Set s, e até mesmo Immutable.js). Para o nosso exemplo, vamos apenas usar matrizes para mantê-lo simples (por enquanto).

V3: Um Traversal e um String Andam em um Bar

Vamos começar com um exemplo motivador. Filtraremos os friends nossos usuários para aqueles que tiverem mais de cinco amigos e, em seguida, extrairemos todos os nomes deles em uma lista. Vamos fazer a filtragem com uma função matching que leva uma função de filtro de A para boolean e produz um Traversal<A> :

Vamos pensar sobre isso antes de mergulharmos. Vamos primeiro realizar uma travessia sobre nossa coleção, o que significa que estamos filtrando a matriz de alguma forma. Mas a filtragem não altera o tipo do tipo de saída, portanto, até agora, a navegação suave. Mas, em seguida, vamos extrair os nomes e, assim, terminaremos com uma lista de strings. Uma tentativa ingênua de se deparar com problemas:

Estamos recebendo um tipo never volta do nosso KeyAt no tipo de retorno. Isso ocorre porque S não representa um objeto Usuário . É um usuário [] . As únicas chaves que poderíamos extrair dele são coisas como length , map , etc. Podemos consertar isso com um pouco de acrobacia:

Onde está Waldo, mas para assinaturas de tipo

Você vê a diferença? Nós mudamos a nossa restrição de tipo S para ser sobre os elementos da coleção, e então apenas dissemos que nossa entrada seria uma matriz de S

Isso funciona muito bem e é um truque útil quando sabemos sobre a estrutura que um contêiner terá (um Array , neste caso) e queremos restringir ou referenciar o tipo de elemento. No entanto, tem uma grande desvantagem e, além disso, eu já disse isso na última frase. Isso requer que saibamos a estrutura que o contêiner terá. Queremos ser capazes de escrever funções genéricas que podem funcionar em muitos tipos diferentes de contêineres, como Map s, Set s, Array e Object s simultaneamente. E para isso precisamos tirar as grandes armas.

Desembale e cole

Lembre-se daqueles tipos condicionais dos quais falamos anteriormente? Podemos usá-los para criar um tipo de utilitário muito poderoso chamado Descompactar:

A primeira coisa que pode saltar em você é a palavra-chave infer . Este é o molho secreto que está fazendo o nosso trabalho por nós. Quando estamos fazendo uma pergunta com um tipo condicional, ou seja, "Isto é dado F uma matriz de A ?", Podemos não saber o tipo exato A infer nos permite dar um nome a esse tipo interior que não sabemos, mas typescript faz. Portanto, o que o Unpack faz nos permite perguntar se um determinado objeto é um de qualquer número de tipos de coleção ( Array , Set , Map , Promise , etc.) e descobrir qual tipo está dentro dessa coleção.

Este é o primeiro passo para a nossa função get , aceitando vários tipos de containers como entrada. Em seguida, vamos criar um tipo de contêiner que englobe todas as coleções pelas quais podemos querer passar.

Então, agora, se voltarmos ao nosso ingênuo V3 e substituirmos todos os nossos Array s por Collection s, e colocarmos um Unpack cuidadosamente colocado, teremos quase algo que funcione.

Você pode ver o problema com o nosso resultado?

TS só sabe que nossa saída é uma Collection<string, any> , mas sabemos que ela deve ser uma string[] . Este erro é porque, bem, isso é exatamente o que dissemos.

Tipos Kinded mais altos

Vamos dar uma pequena digressão e falar sobre o motivo do problema acima. Imagine que você estava escrevendo uma versão do Array::map que funcionaria para todos os tipos diferentes, ou seja, A[] => B[] e Map<K, A> => Map<K, B> . Como você faria isso? (Psiu! Se você quiser isso, está disponível em tons ). A abordagem OO tradicional seria fazer uma interface Mappable e, em seguida, implementarmos o Mappable para todas as classes de contêiner. Mas há um grande problema: isso criará o mesmo problema que temos acima.

A interface Mappable não conhece o tipo da classe container que será implementada, então diz apenas que o map funções retornará um Mappable<B> . Quando implementamos isso em nossa classe de container List , combinamos a assinatura de tipo com nossa interface e nosso List::map retorna um Mappable<B> também. Mas isso significa que perdemos informações de tipo! List::map poderia retornar qualquer outra classe que implementasse o Mappable .

Além disso, isso significa que o TS não sabe que out é uma List , por isso não podemos chamar nenhum método List em nossa saída ou passá-lo para uma função que espera uma List . Isso é um problema real se quisermos ter interfaces comuns sobre contêineres de dados como List s, Map s, Set s, etc. (usuários experientes em TS podem notar que esse caso de uso exato pode ser consertado, mas em geral TS não pode lidar com funções como map , e vamos nos concentrar nos problemas mais gerais.) O que realmente queremos é algo como isto:

Abstraindo sobre contêineres

Essa idéia de ser capaz de dizer F é um recipiente genérico, e nossa função retorna F<A> é chamada de polimorfismo do tipo Higher Kinded , e é uma parte crítica de linguagens como Scala e Haskell. Infelizmente, o TS não suporta isso ( ainda ).

Então vamos fingir.

Functor

O tipo kinded mais comum é Functor , e é exatamente esse Mappable<F, A> acima: ele pega algum tipo F<A> , uma função (a: A) => B e retorna um F<B> , seja o que for que F fosse. Isso realmente representa apenas um tipo que você pode chamar de map . Como mencionado acima, não podemos realmente implementar isso no TS, mas podemos escolher todos os tipos que nós pode querer mapear mais, e escrever uma versão do Functor que lida com qualquer um desses. Como? Com nossos velhos tipos condicionais de amigos:

O funcionamento do pobre homem

Nós estamos pegando nosso tipo F e perguntando por sua vez se é qualquer um destes containers: Array , Object , Set , etc. Se tivermos sucesso, soletramos o que o tipo de retorno F<B> correto deveria ser.

Rascunho final

Tudo bem, isso vem há muito tempo e você foi muito paciente. Vamos revisitar o nosso V3 e reforçá-lo com o nosso Functor :

Funciona!

Vamos dar uma olhada exatamente no que mudou. Em vez de retornar uma coleção , usamos nossa classe Functor para descobrir exatamente qual coleção queríamos retornar. O Functor quer dois parâmetros:

  1. Todo o objeto da coleção que estamos transformando: S
  2. O novo tipo de membro da coleção. Isso é exatamente o mesmo de antes; queremos extrair o tipo que está na chave K nos itens de membro de S Usamos nosso amigo Unpack para extrair o que estiver na coleção S e, em seguida, usamos o KeyAt para obter a chave correta.

Agora vamos dar um passo atrás; nós fizemos isso tudo de forma incremental, então talvez não pareça super legal ainda. Nós temos uma função get que pode pegar algum objeto Traversal genérico e uma string aleatória, e interpretá-los como caminhos abstratos em algum objeto ainda desconhecido. Retornamos uma função que pegará qualquer objeto que corresponda a esse caminho abstrato, interpretará o significado desse caminho para esse objeto e construirá um tipo de retorno preciso e útil para esse caso de uso.

Até pegará erros sutis! Por exemplo, e se namez vez de name ? TS vai pegá-lo e dá uma análise detalhada do que deu errado:

você não pode

A TS fez um tremendo trabalho ao criar um sistema de tipos flexível o suficiente para lidar com todos os truques estranhos que os programadores JavaScript usam. Nós só precisamos nos sentir confortáveis aproveitando seus incríveis poderes.