Digitação estática dinâmica no TypeScript
Publicados: 2022-03-10JavaScript é 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:
- 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, ouserID
deve ser mapeado para um parâmetrouserID
que, bem, contém o ID do usuário! - A
response
ou objeto dereply
.
Aqui queremos preparar uma resposta adequada do servidor para o cliente. Queremos enviar os códigos de status corretos (métodostatus
) 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?
- 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. - Ó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. - Esta é a resposta que queremos enviar de volta. Acessamos os argumentos analisados, mas temos um erro de digitação médio. É
userID
nãouserId
. 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.
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 tipoServerRequest
-
reply
que é do tipoServerReply
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:
-
ServerRequest
torna-se um tipo genérico, conforme indicado pelos colchetes angulares - Definimos um parâmetro genérico chamado
Met
, que é um subconjunto do tipoMethods
- 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:
- 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. - 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.
- 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! - 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:
- 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 oRest
. Por exemplo, se passarmos a rota"/api/users/:userID/orders/:orderID"
paraParseRouteParams
, inferimos"userID"
emP
e"orders/:orderID"
emRest
. Chamamos o mesmo tipo comRest
- É 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. - 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:
- Só podemos passar códigos de status numéricos adequados para
res.status()
-
req.method
é uma das quatro strings possíveis, e quando usamosapp.get
, sabemos que é apenas"GET"
- 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.
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.