paint-brush
Dominando a serialização JSON com segurança de tipo em TypeScriptpor@nodge
8,539 leituras
8,539 leituras

Dominando a serialização JSON com segurança de tipo em TypeScript

por Maksim Zemskov11m2024/02/26
Read on Terminal Reader

Muito longo; Para ler

Este artigo explora os desafios da serialização de dados em TypeScript ao usar o formato JSON. Ele se concentra particularmente nas deficiências das funções JSON.stringify e JSON.parse. Para resolver esses problemas, sugere o uso do tipo JSONCompatible para verificar se um tipo T pode ser serializado com segurança para JSON. Além disso, recomenda a biblioteca Superstruct para desserialização segura de JSON. Este método melhora a segurança de tipo e permite a detecção de erros durante o desenvolvimento.
featured image - Dominando a serialização JSON com segurança de tipo em TypeScript
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


Quase todas as aplicações web requerem serialização de dados. Essa necessidade surge em situações como:


  • Transferência de dados pela rede (por exemplo, solicitações HTTP, WebSockets)
  • Incorporação de dados em HTML (para hidratação, por exemplo)
  • Armazenar dados em um armazenamento persistente (como LocalStorage)
  • Compartilhando dados entre processos (como web workers ou postMessage)


Em muitos casos, a perda ou corrupção de dados pode levar a consequências graves, tornando essencial fornecer um mecanismo de serialização conveniente e seguro que ajude a detectar o maior número possível de erros durante a fase de desenvolvimento. Para esses fins, é conveniente usar JSON como formato de transferência de dados e TypeScript para verificação de código estático durante o desenvolvimento.


TypeScript serve como um superconjunto de JavaScript, que deve permitir o uso contínuo de funções como JSON.stringify e JSON.parse , certo? Acontece que, apesar de todos os seus benefícios, o TypeScript não entende naturalmente o que é JSON e quais tipos de dados são seguros para serialização e desserialização em JSON.


Vamos ilustrar isso com um exemplo.


O problema com JSON no TypeScript

Considere, por exemplo, uma função que salva alguns dados no LocalStorage. Como o LocalStorage não pode armazenar objetos, usamos a serialização JSON aqui:


 interface PostComment { authorId: string; text: string; updatedAt: Date; } function saveComment(comment: PostComment) { const serializedComment = JSON.stringify(comment); localStorage.setItem('draft', serializedComment); }


Também precisaremos de uma função para recuperar os dados do LocalStorage.


 function restoreComment(): PostComment | undefined { const text = localStorage.getItem('draft'); return text ? JSON.parse(text) : undefined; }


O que há de errado com esse código? O primeiro problema é que ao restaurar o comentário, obteremos um tipo string em vez de Date para o campo updatedAt .


Isso acontece porque JSON possui apenas quatro tipos de dados primitivos ( null , string , number , boolean ), bem como arrays e objetos. Não é possível salvar um objeto Date em JSON, assim como outros objetos que se encontram em JavaScript: funções, Mapa, Conjunto, etc.


Quando JSON.stringify encontra um valor que não pode ser representado no formato JSON, ocorre a conversão de tipo. No caso de um objeto Date , obtemos uma string porque o objeto Date implementa o método toJson() , que retorna uma string em vez de um objeto Date .


 const date = new Date('August 19, 1975 23:15:30 UTC'); const jsonDate = date.toJSON(); console.log(jsonDate); // Expected output: "1975-08-19T23:15:30.000Z" const isEqual = date.toJSON() === JSON.stringify(date); console.log(isEqual); // Expected output: true


O segundo problema é que a função saveComment retorna o tipo PostComment , em que o campo de data é do tipo Date . Mas já sabemos que em vez de Date , receberemos um tipo string . O TypeScript poderia nos ajudar a encontrar esse erro, mas por que não?


Acontece que, na biblioteca padrão do TypeScript, a função JSON.parse é digitada como (text: string) => any . Devido ao uso de any , a verificação de tipo está essencialmente desabilitada. Em nosso exemplo, o TypeScript simplesmente aceitou nossa palavra de que a função retornaria um PostComment contendo um objeto Date .


Esse comportamento do TypeScript é inconveniente e inseguro. Nosso aplicativo poderá travar se tentarmos tratar uma string como um objeto Date . Por exemplo, pode quebrar se chamarmos comment.updatedAt.toLocaleDateString() .


Na verdade, em nosso pequeno exemplo, poderíamos simplesmente substituir o objeto Date por um carimbo de data/hora numérico, que funciona bem para serialização JSON. No entanto, em aplicações reais, os objetos de dados podem ser extensos, os tipos podem ser definidos em vários locais e identificar tal erro durante o desenvolvimento pode ser uma tarefa desafiadora.


E se pudéssemos melhorar a compreensão do TypeScript sobre JSON?


Lidando com serialização

Para começar, vamos descobrir como fazer o TypeScript entender quais tipos de dados podem ser serializados com segurança em JSON. Suponha que queiramos criar uma função safeJsonStringify , onde o TypeScript verificará o formato dos dados de entrada para garantir que seja JSON serializável.


 function safeJsonStringify(data: JSONValue) { return JSON.stringify(data); }


Nesta função, a parte mais importante é o tipo JSONValue , que representa todos os valores possíveis que podem ser representados no formato JSON. A implementação é bastante simples:


 type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; };


Primeiro, definimos o tipo JSONPrimitive , que descreve todos os tipos de dados JSON primitivos. Também incluímos o tipo undefined com base no fato de que, quando serializadas, as chaves com valor undefined serão omitidas. Durante a desserialização, essas chaves simplesmente não aparecerão no objeto, o que na maioria dos casos é a mesma coisa.


A seguir, descrevemos o tipo JSONValue . Este tipo usa a capacidade do TypeScript para descrever tipos recursivos, que são tipos que se referem a si mesmos. Aqui, JSONValue pode ser JSONPrimitive , uma matriz de JSONValue ou um objeto onde todos os valores são do tipo JSONValue . Como resultado, uma variável deste tipo JSONValue pode conter matrizes e objetos com aninhamento ilimitado. Os valores dentro deles também serão verificados quanto à compatibilidade com o formato JSON.


Agora podemos testar nossa função safeJsonStringify usando os seguintes exemplos:


 // No errors safeJsonStringify({ updatedAt: Date.now() }); // Yields an error: // Argument of type '{ updatedAt: Date; }' is not assignable to parameter of type 'JSONValue'. // Types of property 'updatedAt' are incompatible. // Type 'Date' is not assignable to type 'JSONValue'. safeJsonStringify({ updatedAt: new Date(); });


Tudo parece funcionar corretamente. A função nos permite passar a data como um número, mas gera um erro se passarmos o objeto Date .


Mas vamos considerar um exemplo mais realista, em que os dados passados para a função são armazenados em uma variável e possuem um tipo descrito.


 interface PostComment { authorId: string; text: string; updatedAt: number; }; const comment: PostComment = {...}; // Yields an error: // Argument of type 'PostComment' is not assignable to parameter of type 'JSONValue'. // Type 'PostComment' is not assignable to type '{ [key: string]: JSONValue; }'. // Index signature for type 'string' is missing in type 'PostComment'. safeJsonStringify(comment);


Agora, as coisas estão ficando um pouco complicadas. O TypeScript não nos permite atribuir uma variável do tipo PostComment a um parâmetro de função do tipo JSONValue , porque "A assinatura do índice para o tipo 'string' está faltando no tipo 'PostComment'".


Então, o que é uma assinatura de índice e por que ela está faltando? Lembra como descrevemos objetos que podem ser serializados no formato JSON?


 type JSONValue = { [key: string]: JSONValue; };


Neste caso, [key: string] é a assinatura do índice. Significa "este objeto pode ter quaisquer chaves na forma de strings, cujos valores possuem o tipo JSONValue ". Então, precisamos adicionar uma assinatura de índice ao tipo PostComment , certo?


 interface PostComment { authorId: string; text: string; updatedAt: number; // Don't do this: [key: string]: JSONValue; };


Fazer isso implicaria que o comentário poderia conter campos arbitrários, o que normalmente não é o resultado desejado ao definir tipos de dados em um aplicativo.


A verdadeira solução para o problema com a assinatura do índice vem de Mapped Types , que permitem iterar recursivamente os campos, mesmo para tipos que não possuem uma assinatura de índice definida. Combinado com genéricos, esse recurso permite converter qualquer tipo de dados T em outro tipo JSONCompatible<T> , que seja compatível com o formato JSON.


 type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; type NotAssignableToJson = | bigint | symbol | Function;


O tipo JSONCompatible<T> é um tipo mapeado que inspeciona se um determinado tipo T pode ser serializado com segurança em JSON. Ele faz isso iterando cada propriedade no tipo T e fazendo o seguinte:


  1. O T[P] extends JSONValue ? T[P] : ... o tipo condicional verifica se o tipo da propriedade é compatível com o tipo JSONValue , garantindo que ela possa ser convertida para JSON com segurança. Quando for esse o caso, o tipo da propriedade permanece inalterado.
  2. O T[P] extends NotAssignableToJson ? never : ... o tipo condicional verifica se o tipo da propriedade não pode ser atribuído ao JSON. Nesse caso, o tipo da propriedade é convertido em never , filtrando efetivamente a propriedade do tipo final.
  3. Se nenhuma dessas condições for atendida, o tipo é verificado recursivamente até que uma conclusão possa ser feita. Dessa forma funciona mesmo que o tipo não possua assinatura de índice.


A unknown extends T ? never :... check no início é usado para evitar que o tipo unknown seja convertido em um tipo de objeto vazio {} , que é essencialmente equivalente ao tipo any .


Outro aspecto interessante é o tipo NotAssignableToJson . Consiste em duas primitivas TypeScript (bigint e símbolo) e o tipo Function , que descreve qualquer função possível. O tipo Function é crucial para filtrar quaisquer valores que não sejam atribuíveis ao JSON. Isso ocorre porque qualquer objeto complexo em JavaScript é baseado no tipo Object e possui pelo menos uma função em sua cadeia de protótipo (por exemplo, toString() ). O tipo JSONCompatible itera sobre todas essas funções, portanto, verificar as funções é suficiente para filtrar qualquer coisa que não seja serializável para JSON.


Agora, vamos usar este tipo na função de serialização:


 function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); }


Agora, a função usa um parâmetro genérico T e aceita o argumento JSONCompatible<T> . Isso significa que é necessário um argumento data do tipo T , que deve ser um tipo compatível com JSON. Agora podemos usar a função com tipos de dados sem assinatura de índice.


A função agora usa um parâmetro genérico T que se estende do tipo JSONCompatible<T> . Isso significa que ele aceita um argumento data do tipo T , que deve ser um tipo compatível com JSON. Como resultado, podemos utilizar a função com tipos de dados que não possuem assinatura de índice.


 interface PostComment { authorId: string; text: string; updatedAt: number; } function saveComment(comment: PostComment) { const serializedComment = safeJsonStringify(comment); localStorage.setItem('draft', serializedComment); }


Essa abordagem pode ser usada sempre que a serialização JSON for necessária, como transferência de dados pela rede, incorporação de dados em HTML, armazenamento de dados em localStorage, transferência de dados entre trabalhadores, etc. Além disso, o auxiliar toJsonValue pode ser usado quando um objeto estritamente digitado sem uma assinatura de índice precisa ser atribuída a uma variável do tipo JSONValue .


 function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } const comment: PostComment = {...}; const data: JSONValue = { comment: toJsonValue(comment) };


Neste exemplo, usar toJsonValue nos permite ignorar o erro relacionado à falta de assinatura de índice no tipo PostComment .


Lidando com desserialização

Quando se trata de desserialização, o desafio é mais simples e mais complexo ao mesmo tempo porque envolve verificações de análise estática e verificações de tempo de execução para o formato dos dados recebidos.


Da perspectiva do sistema de tipos do TypeScript, o desafio é bastante simples. Vamos considerar o seguinte exemplo:


 function safeJsonParse(text: string) { return JSON.parse(text) as unknown; } const data = JSON.parse(text); // ^? unknown


Neste caso, estamos substituindo o tipo de retorno any pelo tipo unknown . Por que escolher unknown ? Essencialmente, uma string JSON pode conter qualquer coisa, não apenas os dados que esperamos receber. Por exemplo, o formato dos dados pode mudar entre diferentes versões do aplicativo ou outra parte do aplicativo pode gravar dados na mesma chave LocalStorage. Portanto, unknown é a escolha mais segura e precisa.


Entretanto, trabalhar com o tipo unknown é menos conveniente do que simplesmente especificar o tipo de dados desejado. Além da conversão de tipo, existem várias maneiras de converter o tipo unknown no tipo de dados necessário. Um desses métodos é utilizar a biblioteca Superstruct para validar dados em tempo de execução e gerar erros detalhados se os dados forem inválidos.


 import { create, object, number, string } from 'superstruct'; const PostComment = object({ authorId: string(), text: string(), updatedAt: number(), }); // Note: we no longer need to manually specify the return type function restoreDraft() { const text = localStorage.getItem('draft'); return text ? create(JSON.parse(text), PostComment) : undefined; }


Aqui, a função create atua como um protetor de tipo, restringindo o tipo à interface Comment desejada. Conseqüentemente, não precisamos mais especificar manualmente o tipo de retorno.


Implementar uma opção de desserialização segura é apenas metade da história. É igualmente crucial não se esquecer de usá-lo ao realizar a próxima tarefa do projeto. Isso se torna particularmente desafiador se uma equipe grande estiver trabalhando no projeto, pois pode ser difícil garantir que todos os acordos e melhores práticas sejam seguidos.


Typescript-eslint pode ajudar nesta tarefa. Esta ferramenta ajuda a identificar todos os casos de uso any . Especificamente, todos os usos de JSON.parse podem ser encontrados e pode-se garantir que o formato dos dados recebidos seja verificado. Mais sobre como se livrar de any tipo em uma base de código pode ser lido no artigo Tornando o TypeScript verdadeiramente "fortemente digitado" .


Conclusão

Aqui estão as funções e tipos de utilitários finais projetados para auxiliar na serialização e desserialização segura de JSON. Você pode testá-los no TS Playground preparado.


 type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; }; type NotAssignableToJson = | bigint | symbol | Function; type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); } function safeJsonParse(text: string): unknown { return JSON.parse(text); }


Eles podem ser usados em qualquer situação em que a serialização JSON seja necessária.


Tenho usado essa estratégia em meus projetos há vários anos e ela demonstrou sua eficácia ao detectar prontamente possíveis erros durante o desenvolvimento de aplicativos.


Espero que este artigo tenha fornecido a você alguns insights novos. Obrigado por ler!

Links Úteis