Quase todas as aplicações web requerem serialização de dados. Essa necessidade surge em situações como:
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.
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?
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:
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.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.
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
.
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" .
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!