Digitação estática dinâmica no TypeScript

Publicados: 2022-03-10
Resumo rápido ↬ Neste artigo, veremos alguns dos recursos mais avançados do TypeScript, como tipos de união, tipos condicionais, tipos literais de modelo e genéricos. Queremos formalizar o comportamento JavaScript mais dinâmico de forma que possamos detectar a maioria dos bugs antes que eles aconteçam. Aplicamos vários aprendizados de todos os capítulos do TypeScript em 50 Lessons, livro que publicamos aqui na Smashing Magazine no final de 2020. Se você tem interesse em aprender mais, não deixe de conferir!

JavaScript é uma linguagem de programação inerentemente dinâmica. Nós, como desenvolvedores, podemos expressar muito com pouco esforço, e a linguagem e seu tempo de execução descobrem o que pretendíamos fazer. É isso que torna o JavaScript tão popular para iniciantes e que torna os desenvolvedores experientes produtivos! Há uma ressalva, porém: precisamos estar alertas! Erros, erros de digitação, comportamento correto do programa: muito disso acontece em nossas cabeças!

Dê uma olhada no exemplo a seguir.

 app.get("/api/users/:userID", function(req, res) { if (req.method === "POST") { res.status(20).send({ message: "Got you, user " + req.params.userId }); } })

Temos um servidor https://expressjs.com/-style que nos permite definir uma rota (ou caminho), e executa um callback se a URL for solicitada.

O retorno de chamada recebe dois argumentos:

  1. O objeto de request .
    Aqui obtemos informações sobre o método HTTP usado (por exemplo, GET, POST, PUT, DELETE), e parâmetros adicionais que entram. Neste exemplo, o userID deve ser mapeado para um parâmetro userID que, bem, contém o ID do usuário!
  2. A response ou objeto de reply .
    Aqui queremos preparar uma resposta adequada do servidor para o cliente. Queremos enviar os códigos de status corretos (método status ) e enviar a saída JSON pela rede.

O que vemos neste exemplo é bastante simplificado, mas dá uma boa ideia do que estamos fazendo. O exemplo acima também está cheio de erros! Dar uma olhada:

 app.get("/api/users/:userID", function(req, res) { if (req.method === "POST") { /* Error 1 */ res.status(20).send({ /* Error 2 */ message: "Welcome, user " + req.params.userId /* Error 3 */ }); } })

Uau! Três linhas de código de implementação e três erros? O que aconteceu?

  1. O primeiro erro é matizado. Embora digamos ao nosso aplicativo que queremos ouvir solicitações GET (daí app.get ), só fazemos algo se o método de solicitação for POST . Neste ponto específico em nosso aplicativo, req.method não pode ser POST . Portanto, nunca enviaríamos nenhuma resposta, o que pode levar a tempos limite inesperados.
  2. Ótimo que enviamos explicitamente um código de status! 20 não é um código de status válido, no entanto. Os clientes podem não entender o que está acontecendo aqui.
  3. Esta é a resposta que queremos enviar de volta. Acessamos os argumentos analisados, mas temos um erro de digitação médio. É userID não userId . Todos os nossos usuários seriam recebidos com “Bem-vindo, usuário indefinido!”. Algo que você definitivamente já viu na natureza!

E coisas assim acontecem! Especialmente em JavaScript. Ganhamos expressividade – nenhuma vez tivemos que nos preocupar com tipos – mas temos que prestar muita atenção ao que estamos fazendo.

É também aqui que o JavaScript recebe muita reação de programadores que não estão acostumados a linguagens de programação dinâmicas. Eles geralmente têm compiladores apontando para possíveis problemas e detectando erros antecipadamente. Eles podem parecer esnobes quando desaprovam a quantidade de trabalho extra que você precisa fazer em sua cabeça para garantir que tudo funcione bem. Eles podem até dizer que o JavaScript não tem tipos. O que não é verdade.

Anders Hejlsberg, o principal arquiteto do TypeScript, disse em sua palestra do MS Build 2017 que “ não é que o JavaScript não tenha um sistema de tipos. Simplesmente não há como formalizá-lo ”.

E este é o objetivo principal do TypeScript. O TypeScript quer entender seu código JavaScript melhor do que você. E onde o TypeScript não consegue descobrir o que você quer dizer, você pode ajudar fornecendo informações extras de tipo.

Mais depois do salto! Continue lendo abaixo ↓

Digitação básica

E é isso que vamos fazer agora. Vamos pegar o método get do nosso servidor no estilo Express e adicionar informações de tipo suficientes para que possamos excluir o maior número possível de categorias de erros.

Começamos com algumas informações básicas de tipo. Temos um objeto app que aponta para uma função get . A função get recebe path , que é uma string, e um callback.

 const app = { get, /* post, put, delete, ... to come! */ }; function get(path: string, callback: CallbackFn) { // to be implemented --> not important right now }

Enquanto string é um tipo básico chamado primitivo , CallbackFn é um tipo composto que temos que definir explicitamente.

CallbackFn é um tipo de função que recebe dois argumentos:

  • req , que é do tipo ServerRequest
  • reply que é do tipo ServerReply

CallbackFn retorna void .

 type CallbackFn = (req: ServerRequest, reply: ServerReply) => void;

ServerRequest é um objeto bastante complexo na maioria dos frameworks. Fazemos uma versão simplificada para fins de demonstração. Passamos uma string de method , para "GET" , "POST" , "PUT" , "DELETE" , etc. Também possui um registro de params . Registros são objetos que associam um conjunto de chaves a um conjunto de propriedades. Por enquanto, queremos permitir que cada chave de string seja mapeada para uma propriedade de string . Refatoramos este mais tarde.

 type ServerRequest = { method: string; params: Record<string, string>; };

Para ServerReply , apresentamos algumas funções, sabendo que um objeto ServerReply real tem muito mais. Uma função de send recebe um argumento opcional com os dados que queremos enviar. E temos a possibilidade de definir um código de status com a função de status .

 type ServerReply = { send: (obj?: any) => void; status: (statusCode: number) => ServerReply; };

Isso já é algo, e podemos descartar alguns erros:

 app.get("/api/users/:userID", function(req, res) { if(req.method === 2) { // ^^^^^^^^^^^^^^^^^ Error, type number is not assignable to string res.status("200").send() // ^^^^^ Error, type string is not assignable to number } })

Mas ainda podemos enviar códigos de status errados (qualquer número é possível) e não temos ideia sobre os possíveis métodos HTTP (qualquer string é possível). Vamos refinar nossos tipos.

Conjuntos menores

Você pode ver os tipos primitivos como um conjunto de todos os valores possíveis dessa determinada categoria. Por exemplo, string inclui todas as strings possíveis que podem ser expressas em JavaScript, number inclui todos os números possíveis com precisão de float duplo. boolean inclui todos os valores booleanos possíveis, que são true e false .

O TypeScript permite refinar esses conjuntos em subconjuntos menores. Por exemplo, podemos criar um tipo Method que inclui todas as strings possíveis que podemos receber para métodos HTTP:

 type Methods= "GET" | "POST" | "PUT" | "DELETE"; type ServerRequest = { method: Methods; params: Record<string, string>; };

O Method é um conjunto menor do conjunto de string maior. Method é um tipo de união de tipos literais. Um tipo literal é a menor unidade de um determinado conjunto. Uma sequência literal. Um número literal. Não há ambiguidade. É apenas "GET" . Você os coloca em uma união com outros tipos literais, criando um subconjunto de quaisquer tipos maiores que você tenha. Você também pode fazer um subconjunto com tipos literais de string e number ou diferentes tipos de objetos compostos. Há muitas possibilidades de combinar e colocar tipos literais em uniões.

Isso tem um efeito imediato em nosso retorno de chamada do servidor. De repente, podemos diferenciar entre esses quatro métodos (ou mais, se necessário) e podemos esgotar todas as possibilidades no código. O TypeScript nos guiará:

 app.get("/api/users/:userID", function (req, res) { // at this point, TypeScript knows that req.method // can take one of four possible values switch (req.method) { case "GET": break; case "POST": break; case "DELETE": break; case "PUT": break; default: // here, req.method is never req.method; } });

Com cada instrução case que você faz, o TypeScript pode fornecer informações sobre as opções disponíveis. Experimente você mesmo. Se você esgotou todas as opções, o TypeScript informará em sua ramificação default que isso never pode acontecer. Este é literalmente o tipo never , o que significa que você possivelmente atingiu um estado de erro que precisa ser tratado.

Essa é uma categoria de erros a menos. Agora sabemos exatamente quais métodos HTTP possíveis estão disponíveis.

Podemos fazer o mesmo para códigos de status HTTP, definindo um subconjunto de números válidos que o statusCode pode receber:

 type StatusCode = 100 | 101 | 102 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 420 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 444 | 449 | 450 | 451 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 598 | 599; type ServerReply = { send: (obj?: any) => void; status: (statusCode: StatusCode) => ServerReply; };

O tipo StatusCode é novamente um tipo de união. E com isso, excluímos outra categoria de erros. De repente, um código como esse falha:

 app.get("/api/user/:userID", (req, res) => { if(req.method === "POS") { // ^^^^^^^^^^^^^^^^^^^ 'Methods' and '"POS"' have no overlap. res.status(20) // ^^ '20' is not assignable to parameter of type 'StatusCode' } })
E nosso software fica muito mais seguro! Mas nos podemos fazer mais!

Digite genéricos

Quando definimos uma rota com app.get , sabemos implicitamente que o único método HTTP possível é "GET" . Mas com nossas definições de tipo, ainda temos que verificar todas as partes possíveis da união.

O tipo para CallbackFn está correto, pois poderíamos definir funções de retorno de chamada para todos os métodos HTTP possíveis, mas se chamarmos explicitamente app.get , seria bom salvar algumas etapas extras que são necessárias apenas para cumprir as tipagens.

Os genéricos do TypeScript podem ajudar! Os genéricos são um dos principais recursos do TypeScript que permitem obter o comportamento mais dinâmico dos tipos estáticos. Em TypeScript em 50 lições, passamos os últimos três capítulos investigando todos os meandros dos genéricos e sua funcionalidade exclusiva.

O que você precisa saber agora é que queremos definir ServerRequest de forma que possamos especificar uma parte de Methods em vez de todo o conjunto. Para isso, usamos a sintaxe genérica onde podemos definir parâmetros como faríamos com funções:

 type ServerRequest<Met extends Methods> = { method: Met; params: Record<string, string>; };

Isto é o que acontece:

  1. ServerRequest torna-se um tipo genérico, conforme indicado pelos colchetes angulares
  2. Definimos um parâmetro genérico chamado Met , que é um subconjunto do tipo Methods
  3. Usamos esse parâmetro genérico como uma variável genérica para definir o método.

Também encorajo você a conferir meu artigo sobre nomenclatura de parâmetros genéricos.

Com essa alteração, podemos especificar diferentes ServerRequest s sem duplicar as coisas:

 type OnlyGET = ServerRequest<"GET">; type OnlyPOST = ServerRequest<"POST">; type POSTorPUT = ServerRquest<"POST" | "PUT">;

Como alteramos a interface de ServerRequest , temos que fazer alterações em todos os nossos outros tipos que usam ServerRequest , como CallbackFn e a função get :

 type CallbackFn<Met extends Methods> = ( req: ServerRequest<Met>, reply: ServerReply ) => void; function get(path: string, callback: CallbackFn<"GET">) { // to be implemented }

Com a função get , passamos um argumento real para nosso tipo genérico. Sabemos que este não será apenas um subconjunto de Methods , sabemos exatamente com qual subconjunto estamos lidando.

Agora, quando usamos app.get , temos apenas um valor possível para req.method :

 app.get("/api/users/:userID", function (req, res) { req.method; // can only be get });

Isso garante que não presumimos que métodos HTTP como "POST" ou similares estejam disponíveis quando criamos um retorno de chamada app.get . Sabemos exatamente com o que estamos lidando neste momento, então vamos refletir isso em nossos tipos.

Já fizemos muito para garantir que request.method seja razoavelmente digitado e represente o estado real das coisas. Um bom benefício que obtemos com a subconfiguração do tipo de união Methods é que podemos criar uma função de retorno de chamada de propósito geral fora do app.get que é segura para o tipo:

 const handler: CallbackFn<"PUT" | "POST"> = function(res, req) { res.method // can be "POST" or "PUT" }; const handlerForAllMethods: CallbackFn<Methods> = function(res, req) { res.method // can be all methods }; app.get("/api", handler); // ^^^^^^^ Nope, we don't handle "GET" app.get("/api", handlerForAllMethods); // This works

Parâmetros de digitação

O que ainda não tocamos é digitar o objeto params . Até agora, obtemos um registro que permite acessar todas as chaves de string . É nossa tarefa agora tornar isso um pouco mais específico!

Fazemos isso adicionando outra variável genérica. Um para métodos, um para as possíveis chaves em nosso Record :

 type ServerRequest<Met extends Methods, Par extends string = string> = { method: Met; params: Record<Par, string>; };

A variável de tipo genérico Par pode ser um subconjunto do tipo string e o valor padrão é cada string. Com isso, podemos dizer ao ServerRequest quais chaves esperamos:

 // request.method = "GET" // request.params = { // userID: string // } type WithUserID = ServerRequest<"GET", "userID">

Vamos adicionar o novo argumento à nossa função get e ao tipo CallbackFn , para que possamos definir os parâmetros solicitados:

 function get<Par extends string = string>( path: string, callback: CallbackFn<"GET", Par> ) { // to be implemented } type CallbackFn<Met extends Methods, Par extends string> = ( req: ServerRequest<Met, Par>, reply: ServerReply ) => void;

Se não Par explicitamente, o tipo funcionará como estamos acostumados, pois o padrão de Par é string . Se definirmos, de repente teremos uma definição adequada para o objeto req.params !

 app.get<"userID">("/api/users/:userID", function (req, res) { req.params.userID; // Works!! req.params.anythingElse; // doesn't work!! });

Isso é ótimo! Há uma pequena coisa que pode ser melhorada, no entanto. Ainda podemos passar cada string para o argumento path de app.get . Não seria melhor se pudéssemos refletir o Par lá também?

Nós podemos! Com o lançamento da versão 4.1, o TypeScript é capaz de criar tipos literais de modelo . Sintaticamente, eles funcionam como literais de modelo de string, mas em um nível de tipo. Onde pudemos dividir a string definida em subconjuntos com tipos literais de string (como fizemos com Methods), os tipos literais de modelo nos permitem incluir um espectro inteiro de strings.

Vamos criar um tipo chamado IncludesRouteParams , onde queremos ter certeza de que Par está incluído corretamente no estilo Express de adicionar dois pontos na frente do nome do parâmetro:

 type IncludesRouteParams<Par extends string> = | `${string}/:${Par}` | `${string}/:${Par}/${string}`;

O tipo genérico IncludesRouteParams recebe um argumento, que é um subconjunto de string . Ele cria um tipo de união de dois literais de modelo:

  1. O primeiro literal de modelo começa com qualquer string e inclui um caractere / seguido por um caractere : , seguido pelo nome do parâmetro. Isso garante que capturamos todos os casos em que o parâmetro está no final da string de rota.
  2. O segundo literal de modelo começa com qualquer string , seguido pelo mesmo padrão de / , : e o nome do parâmetro. Então temos outro caractere / , seguido por qualquer string. Essa ramificação do tipo união garante a captura de todos os casos em que o parâmetro está em algum lugar dentro de uma rota.

É assim que IncludesRouteParams com o nome de parâmetro userID se comporta com diferentes casos de teste:

 const a: IncludeRouteParams<"userID"> = "/api/user/:userID" // const a: IncludeRouteParams<"userID"> = "/api/user/:userID/orders" // const a: IncludeRouteParams<"userID"> = "/api/user/:userId" // const a: IncludeRouteParams<"userID"> = "/api/user" // const a: IncludeRouteParams<"userID"> = "/api/user/:userIDAndmore" //

Vamos incluir nosso novo tipo de utilitário na declaração da função get .

 function get<Par extends string = string>( path: IncludesRouteParams<Par>, callback: CallbackFn<"GET", Par> ) { // to be implemented } app.get<"userID">( "/api/users/:userID", function (req, res) { req.params.userID; // YEAH! } );

Excelente! Obtemos outro mecanismo de segurança para garantir que não perdemos a adição dos parâmetros à rota real! Quão poderoso.

Ligações genéricas

Mas adivinhe, eu ainda não estou feliz com isso. Existem alguns problemas com essa abordagem que se tornam aparentes no momento em que suas rotas ficam um pouco mais complexas.

  1. O primeiro problema que tenho é que precisamos declarar explicitamente nossos parâmetros no parâmetro de tipo genérico. Temos que vincular Par a "userID" , mesmo que o especifiquemos de qualquer maneira no argumento path da função. Isso não é JavaScript-y!
  2. Essa abordagem lida apenas com um parâmetro de rota. No momento em que adicionamos uma união, por exemplo, "userID" | "orderId" "userID" | "orderId" a verificação à prova de falhas é satisfeita com apenas um desses argumentos disponível. É assim que os conjuntos funcionam. Pode ser um, ou outro.

Deve haver uma maneira melhor. E aqui está. Caso contrário, este artigo terminaria com uma nota muito amarga.

Vamos inverter a ordem! Não vamos tentar definir os parâmetros de rota em uma variável de tipo genérico, mas sim extrair as variáveis ​​do path que passamos como o primeiro argumento de app.get .

Para chegar ao valor real, temos que ver como a ligação genérica funciona no TypeScript. Vamos pegar esta função de identity por exemplo:

 function identity<T>(inp: T) : T { return inp }

Pode ser a função genérica mais chata que você já viu, mas ilustra um ponto perfeitamente. identity recebe um argumento e retorna a mesma entrada novamente. O tipo é o tipo genérico T e também retorna o mesmo tipo.

Agora podemos vincular T a string , por exemplo:

 const z = identity<string>("yes"); // z is of type string

Essa vinculação explicitamente genérica garante que apenas passemos strings para identity e, como vinculamos explicitamente, o tipo de retorno também é string . Se esquecermos de vincular, algo interessante acontece:

 const y = identity("yes") // y is of type "yes"

Nesse caso, o TypeScript infere o tipo do argumento que você passa e vincula T ao tipo literal de string "yes" . Essa é uma ótima maneira de converter um argumento de função em um tipo literal, que usamos em nossos outros tipos genéricos.

Vamos fazer isso adaptando app.get .

 function get<Path extends string = string>( path: Path, callback: CallbackFn<"GET", ParseRouteParams<Path>> ) { // to be implemented }

Removemos o tipo genérico Par e adicionamos Path . Path pode ser um subconjunto de qualquer string . Definimos path para esse tipo genérico Path , o que significa que no momento em que passamos um parâmetro para get , capturamos seu tipo literal de string. Passamos Path para um novo tipo genérico ParseRouteParams que ainda não criamos.

Vamos trabalhar em ParseRouteParams . Aqui, mudamos a ordem dos eventos novamente. Em vez de passar os parâmetros de rota solicitados para o genérico para ter certeza de que o caminho está correto, passamos o caminho da rota e extraímos os possíveis parâmetros de rota. Para isso, precisamos criar um tipo condicional.

Tipos condicionais e tipos literais de modelo recursivo

Os tipos condicionais são sintaticamente semelhantes ao operador ternário em JavaScript. Você verifica uma condição e, se a condição for atendida, você retorna a ramificação A, caso contrário, você retorna a ramificação B. Por exemplo:

 type ParseRouteParams<Rte> = Rte extends `${string}/:${infer P}` ? P : never;

Aqui, verificamos se Rte é um subconjunto de cada caminho que termina com o parâmetro no final do estilo Express (com um "/:" anterior). Se sim, inferimos essa string. O que significa que capturamos seu conteúdo em uma nova variável. Se a condição for atendida, retornamos a string recém-extraída, caso contrário, retornamos never, como em: “Não há parâmetros de rota”,

Se tentarmos, obteremos algo assim:

 type Params = ParseRouteParams<"/api/user/:userID"> // Params is "userID" type NoParams = ParseRouteParams<"/api/user"> // NoParams is never --> no params!

Ótimo, isso já é muito melhor do que fizemos anteriormente. Agora, queremos pegar todos os outros parâmetros possíveis. Para isso, temos que adicionar outra condição:

 type ParseRouteParams<Rte> = Rte extends `${string}/:${infer P}/${infer Rest}` ? P | ParseRouteParams<`/${Rest}`> : Rte extends `${string}/:${infer P}` ? P : never;

Nosso tipo condicional funciona agora da seguinte forma:

  1. Na primeira condição, verificamos se existe um parâmetro de rota em algum lugar entre a rota. Nesse caso, extraímos o parâmetro de rota e tudo o mais que vem depois disso. Retornamos o parâmetro de rota recém-encontrado P em uma união onde chamamos o mesmo tipo genérico recursivamente com o Rest . Por exemplo, se passarmos a rota "/api/users/:userID/orders/:orderID" para ParseRouteParams , inferimos "userID" em P e "orders/:orderID" em Rest . Chamamos o mesmo tipo com Rest
  2. É aqui que entra a segunda condição. Aqui verificamos se existe um tipo no final. Este é o caso de "orders/:orderID" . "orderID" e retornamos esse tipo literal.
  3. Se não houver mais nenhum parâmetro de rota, retornaremos never.

Dan Vanderkam mostra um tipo semelhante e mais elaborado para ParseRouteParams , mas o que você vê acima também deve funcionar. Se experimentarmos nosso ParseRouteParams recém-adaptado, obteremos algo assim:

 // Params is "userID" type Params = ParseRouteParams<"/api/user/:userID"> // MoreParams is "userID" | "orderID" type MoreParams = ParseRouteParams<"/api/user/:userID/orders/:orderId">

Vamos aplicar esse novo tipo e ver como fica nosso uso final de app.get .

 app.get("/api/users/:userID/orders/:orderID", function (req, res) { req.params.userID; // YES!! req.params.orderID; // Also YES!!! });

Uau. Isso se parece com o código JavaScript que tínhamos no início!

Tipos estáticos para comportamento dinâmico

Os tipos que acabamos de criar para uma função app.get garantem que excluímos vários erros possíveis:

  1. Só podemos passar códigos de status numéricos adequados para res.status()
  2. req.method é uma das quatro strings possíveis, e quando usamos app.get , sabemos que é apenas "GET"
  3. Podemos analisar parâmetros de rota e garantir que não haja erros de digitação em nosso retorno de chamada

Se observarmos o exemplo do início deste artigo, obtemos as seguintes mensagens de erro:

 app.get("/api/users/:userID", function(req, res) { if (req.method === "POST") { // ^^^^^^^^^^^^^^^^^^^^^ // This condition will always return 'false' // since the types '"GET"' and '"POST"' have no overlap. res.status(20).send({ // ^^ // Argument of type '20' is not assignable to // parameter of type 'StatusCode' message: "Welcome, user " + req.params.userId // ^^^^^^ // Property 'userId' does not exist on type // '{ userID: string; }'. Did you mean 'userID'? }); } })

E tudo isso antes de realmente executarmos nosso código! Servidores no estilo Express são um exemplo perfeito da natureza dinâmica do JavaScript. Dependendo do método que você chama, da string que você passa para o primeiro argumento, muitas mudanças de comportamento dentro do callback. Tome outro exemplo e todos os seus tipos parecem totalmente diferentes.

Mas com alguns tipos bem definidos, podemos capturar esse comportamento dinâmico ao editar nosso código. Em tempo de compilação com tipos estáticos, não em tempo de execução quando as coisas explodem!

E este é o poder do TypeScript. Um sistema de tipo estático que tenta formalizar todo o comportamento dinâmico do JavaScript que todos conhecemos tão bem. Se você quiser experimentar o exemplo que acabamos de criar, vá até o playground do TypeScript e mexa nele.


TypeScript em 50 lições por Stefan Baumgartner Neste artigo, abordamos muitos conceitos. Se você quiser saber mais, confira o TypeScript em 50 lições, onde você obtém uma introdução suave ao sistema de tipos em lições pequenas e facilmente digeríveis. As versões de e-book estão disponíveis imediatamente, e o livro impresso será uma ótima referência para sua biblioteca de codificação.