Técnicas avançadas com TypeScript - #SemanaTS dia 4

technology11 min

porLucas Santos

Parte 4 de 5 da série Semana TS

Estivemos falando bastante sobre tipos, passamos com algumas boas e más práticas de código usando TypeScript, mas ainda eu sinto que estamos com algo faltando aqui. Esse algo são as técnicas não tão convencionais que você vai ver por ai.

Eu não gosto de chamar de técnicas avançadas, mas, na falta de um nome melhor para descrevê-las, vou manter assim. Essas técnicas não são nada de outro mundo, mas são usos muito inteligentes do sistema de tipos para fazer com que ele trabalhe para você mais facilmente. Mas chega de introdução e vamos falar da nossa primeira técnica!

Satisfies#

Essa é uma “técnica” super recente, eu já falei dela aqui no blog antes. Eu não chamaria nem técnica porque ela é um operador novo, porém o uso desse operador é uma técnica bem interessante! Esse é o satisfies.

Quando estamos lidando com tipos, um dos problemas que ocorrem frequentemente é que os tipos mais genéricos que são inferidos pelo compilador acabam por passar por cima dos tipos mais específicos que a gente quer, vamos a um exemplo que a gente já usou antes, com os pontos 3D:

Veja que a chamada para toFixed vai retornar um erro, como deve ser, porque temos que verificar se a propriedade existe, mas podemos associar um valor não existente a uma variável de qualquer forma, no nosso caso o x.

Quero deixar claro que, neste caso específico, associar o x a uma variável não faria tanta diferença assim porque o TS vai tipar ponto.x como number | undefined, ou seja, vamos ter que checar do mesmo jeito.

Isso acontece porque, mesmo que a gente saiba que o nosso objeto só tenha uma das chaves, o TS não consegue inferir isso corretamente porque a única informação que demos para ele foi que nosso objeto pode possuir uma ou duas propriedades. Mas se a gente usar o operador satisfies a coisa muda de figura:

Agora temos a inferência correta, e a tipagem de ponto pelo será { y: number }, ou seja, é como se o compilador pegasse o valor da sua variável, fosse até o objeto que você definiu como satisfies, olhasse lá, e retornasse pra você somente as propriedades da sua variável que existem naquele objeto.

Outro uso bastante interessante do satisfies é para garantir que propriedades diferentes tenham tipos diferentes a partir de um tipo mais genérico, por exemplo, um objeto misto:

Veja que não podemos usar o método toUpperCase() porque a união de string | number não possui esse método, mesmo nome sendo uma string, mas se dissermos para o TS que esse objeto mais específico satisfaz uma forma mais genérica, então podemos ter a nossa inferência correta:

Agora, o tipo de carro vai ser { nome: string, ano: number, cor: string } ao invés de um Record<Props, string|number> que tipa todos os valores como string | number.

Perceba que, em ambos os exemplos, nós tivemos que tirar a tipagem da variável e passar diretamente para o satisfies.

Inferência profunda com as const#

Esse caso merece estar separado porque é uma das formas mais legais de usar o satisfies. Mas você também pode dizer que um tipo é uma constante, usando let v = 20 as const, e o TS vai tipar esse valor como type v = 20, um tipo literal.

Vamos dizer que temos um objeto de rotas de uma API. Esse objeto tem a seguinte interface:

Até agora, a inferência de tipos do TS está tipando esse objeto da seguinte forma:

Se usarmos o satisfies Rotas no final do objeto, vamos mudar um pouco essa inferência:

Veja que os booleanos agora são literais true ou false. Mas se tivermos uma função como essa:

Não podemos chamar a nossa rota porque o tipo literal / é diferente do tipo mais genérico string, para isso vamos usar as const no nosso objeto:

E agora vamos olhar os nossos tipos novamente:

Veja que eles estão totalmente inferidos como readonly e com os tipos literais, o que permite que a gente passe para a nossa função o valor correto.

Branded types#

Branded types são uma forma de validar determinados tipos de dados contra um determinado padrão, por exemplo, se quisermos validar que um valor monetário é válido, precisamos de duas informações: a moeda e a quantidade dessa moeda.

Mas cada moeda, apesar de ter valores iguais, são completamente diferentes, não podemos pagar 100 reais em algo que vale 100 dólares porque R$100 não é o mesmo que $100. Para isso temos os branded types.

Branded são tipos que possuem uma marca (brand) ou tag, e podem ser definidos assim:

A intersecção de dois tipos é um novo tipo contendo ambas as propriedades dos demais tipos. Por padrão (mas não obrigatório), um branded type é a junção de um tipo primitivo com um objeto que contém __brand ou __tag (com 2 _).

Essa é uma definição da comuniade, você pode chamar o seu objeto da forma que quiser.

Mas perceba que, se você tentar rodar esse código, vamos ter um erro dizendo que não podemos associar 200 a Dolares, isto porque nenhum branded type é associável por padrão, eles precisam ser forçados ou verificados.

Vamos falar da verificação mais ainda aqui, mas por hora podemos fazer a seguinte malandragem só para eu poder mostrar para você qual é o real uso deles:

Agora vamos dizer que tenhamos que converter nossas moedas, para isso vamos criar uma função como essa:

Agora temos uma função que só aceita reais e só devolve dólares. Então garantimos que os retornos dessas funções não são os primitivos principais number mas sim uma variação deles que não podem ser especificadas diretamente.

Se criarmos um tipo para os reais e chamarmos a função, não vamos ter nenhum erro:

Esta é a forma de trabalhar com branded types, eles precisam ser recebidos ou como parâmetros já verificados, ou como dados externos que precisam ser verificados e convertidos em um tipo branded.

Nota: JAMAIS use o type cast direto (com as <tipo>) para criar um branded type, já vamos ver outras formas mais seguras de se fazer isso.

Voltando ao assunto de generics de ontem, você consegue ver uma forma de melhorar o nosso branded type? Podemos usar um generic para criar um tipo Branded<T,B>:

Type guards e assertion functions#

Estas são as duas formas que podemos verificar e criar um type cast de um tipo normal para um branded type como eu falei anteriormente. Enquanto ambas podem ser usadas para a mesma coisa, assertions functions e type guards são fundamentalmente diferentes.

Assertion Functions#

Assertion functions, como o nome já diz, são funções que se certificam que um valor passado é de um determinado tipo, caso elas não sejam, temos um erro. No TypeScript, assertion functions são definidas com a keyword asserts <valor> is <tipo>:

Este é um exemplo muito útil para demonstrarmos o poder dos branded types em conjunto com as assertion functions. Quando estamos trabalhando com bancos de dados, temos que garantir que um determinado valor seja um UUID e não qualquer string normal, para isso usamos uma assertion function como essa:

Veja que estamos lançando um erro caso nossa regex não seja válida, isso garante que seja o que for que precisa dessa string depois, ela vai ser um UUID.

Se você quer saber qual é a regex do UUID que eu cortei por conta de espaço, é essa aqui: ^_[0__-__9a__-__fA__-__F]_{8}\b-_[0__-__9a__-__fA__-__F]_{4}\b-_[0__-__9a__-__fA__-__F]_{4}\b-_[0__-__9a__-__fA__-__F]_{4}\b-_[0__-__9a__-__fA__-__F]_{12}$

Assim, podemos usar da seguinte forma:

Type Guards#

Os type guards são quase que exatamente a mesma coisa das assertion functions, porém ao invés de darem um throw no erro, vão retornar um booleano garantindo que eles são de um determinado tipo, usando a sintaxe <valor> is <tipo>:

Type guards são muito úteis quando você quer validar algum tipo, mas não quer que o app lançe um erro parando a execução, o que é muito útil, por exemplo, se o seu valor pode ser convertido posteriormente para o tipo necessário.

Junto com as assertion functions, os type guards são as formas mais seguras de se garantir que um determinado tipo é de uma determinada “brand”, mas claro que você também pode usar funções normais que retornem o valor como valor as Brand, dado que você já vai fazer a validação antes do retorno.

Enums#

Enumeradores estão presentes na maioria das linguagens de programação que são estaticamente tipadas desde sempre. Porém eles ainda não são uma realidade no JavaScript. O TS implementa o padrão de enumerador, e eles não são considerados uma técnica avançada, mas saber quando usar enums e quando não usar sim.

Existem diversas pessoas que são contra e outras a favor do uso de enums, eu particularmente gosto bastante de usá-los, mas temos que concordar que eles possuem desvantagens, principalmente por serem o único construto do TS que gera um código JavaScript. Isso mesmo, se você escrever um enum como esse:

Diferentemente do que o TypeScript faz com o resto dos tipos (a chamada type erasure) e os apaga do código final. Esse enum vai ser representado da seguinte forma no código real, de produção:

Isso acontece porque enums podem ser usados tanto como tipos como quanto valores:

Na função acima, você pode ver que o parâmetro a é do tipo Animal e chamamos a função com o valor do enum. E um detalhe importante é que não podemos usar a string, ou seja, não poderíamos chamar setAnimal('Dog') porque 'Dog' não é um valor do enum.

Isso não é um grande problema, mas infelizmente, quando recebemos valores de fora como APIs e etc, temos que fazer um type cast em alguns casos para satisfazer o enum.

Pessoalmente, eu não vejo problema, ainda mais por que não vamos ver esse código ou mexer nele manualmente, mas muitos acreditam que não é uma boa opção. Mas quais são as outras opções?

Disjoint unions#

Uma outra forma de se fazer enums é o uso de disjoint unions, que são uniões de tipos literais:

Aqui a gente não tem o código do enum, mas também não podemos usar os tipos dele em nenhum lugar , o que faz com que a gente tenha essas strings perdidas no código. Em um sistema grande isso pode ser muito problemático.

Você até pode “resolver” isso associando cada valor a uma constante e usando as constantes como parâmetros, mas isso não é muito melhor.

Dicionário de constantes#

Outra forma é criar um dicionário:

Ele deixa a gente usar tanto as strings quanto o valor do dicionário, mas a gente poderia passar qualquer string ali que também funcionaria, o que é péssimo.

Para resolver isso podemos usar um dicionário constante de constantes (complicado), basicamente é adicionar um as const no final, isso faz com que o objeto se torne uma disjoint union de strings:

Constant enums#

Existe outra forma de declarar um enum, que é um enum constante, ou seja, quando você compilar o código, todos os valores que foram usados no enum vão ser colocados de forma literal no código final, ou seja:

Nesse código, além de não podermos usar os valores como string (porque temos um enum) o JavaScript final fica assim:

Veja que o enum deixou de existir, tirando o comentário, você nem saberia que é um enum. Isso é ótimo quando você está criando suas próprias aplicações porque existe uma redução notável de memória e aumento de velocidade em projetos grandes, mas a própria documentação oficial diz que eles não devem ser compartilhados com outros projetos, como falamos no dia 2 com os arquivos declarativos.

A única forma de compartilhar const enums é ativando uma configuração chamada preserveConstEnums que, essencialmente, faz com que o const enum seja um enum normal.

Pra você treinar#

Bora corrigir os exercícios de ontem? Os tipos Flatten e FlattenDeep podem ser encontrados aqui. Já a implementação das classes pode ser encontrada aqui.

Para o desafio de hoje:

  • Usando as declarações de tipos que vimos antes, estenda os métodos setTimeout e setInterval nativos do JavaScript para retornar um branded type do tipo TimeoutID e IntervalID, depois estenda as funções clearTimeout e clearInterval para aceitar apenas os branded types como parâmetros e não qualquer número.

Não esquece de deixar o seu feedback sobre a #SemanaTS aqui nesse formulário!

Notas de rodapé#

  1. Meu ex colega de empresa, Robin Pokorny, tem um artigo muito bom sobre isso no blog dele, vou tentar resumir esse ponto com minhas opiniões, mas vale a leitura.