TypeScript: Validação em runtime com Zod

Silvério Vale • 28 de janeiro de 2024

Código ofuscado no fundo. Texto TypeScript + Zod, com as logos das ferramentas.

Você já se deparou com aquele bug "inexplicável", naquele código que funcionava até ontem e, de repente, parou de funcionar? E, depois de horas quebrando a cabeça, descobriu que alguém no backend resolveu mudar o nome de um campo da response e não falou nada? Eu já. Muitas vezes. E o TypeScript não ajuda muito nessa hora...

Você já deve saber que o TypeScript não afeta runtime, mas já parou para pensar que isso significa que a tipagem de qualquer elemento externo é apenas uma expectativa, e não uma validação real? O TS (TypeScript) não conhece de verdade o retorno da API. Ele confia na sua definição de interface.

Então, o que podemos fazer para proteger nossos sistemas de mudanças em dependências externas? É sobre isso que vou falar a seguir.

O problema

O TypeScript não é uma linguagem compreendida diretamente pelo browser. Durante o build, ele é transpilado para JavaScript e sai de cena. Isso significa que, por mais que seja de grande valia durante o desenvolvimento, as tipagens do TS não têm qualquer efeito em tempo de execução.

Não me entenda mal. Ele é extremamente útil, porque vai garantir que todo o código interno da sua aplicação esteja tipado corretamente. A menos que...

A menos que você esteja "trapaceando"

O que eu quero dizer com "trapacear"? Me refiro a usar any ou as no seu código. Simplesmente não faça isso. Não faça mesmo. E, se por algum motivo que foge do seu controle, você precisar usar um ou outro, deixe um comentário em cima explicando por que você teve que fazer isso.

A questão é que eles "desligam" o TypeScript. Quando você usa um any ou um as, você quebra o fluxo de validação de tipagem. Basicamente, você está dizendo para o TypeScript que você sabe o que está fazendo. Mas, na verdade, você está apenas criando uma armadilha para si mesmo e para o seu time. Portanto, vou reforçar: não use any e não use as. Se você seguir esse conselho, poderá confiar no TypeScript para garantir a tipagem interna do seu código.

Contudo, ainda temos um cenário um pouco mais complicado.

Tipagem na integração com sistemas externos

O TS garante a tipagem apenas do que ele conhece. Porém, quando integramos com algum sistema externo - seja uma API de serviço, uma base de dados, ou o que quer que seja - o TypeScript não conhece o código do outro lado. Assim, ele vai confiar completamente na interface que você fornecer para ele.

Ora, o problema é que aquilo é apenas uma expectativa. E não tem como ser de outra forma, pois não existe uma conexão direta com o código do outro lado. E, para piorar a situação, o TypeScript não está lá no momento em que a integração é consumida - em runtime. Então, ele sequer vai te avisar que você recebeu algo diferente do que estava esperando.

E por que isso ocorreria?

Bom, as possibilidades são muitas:

  • Você pode ter cadastrado uma interface errada;
  • Pode ser que os desenvolvedores do sistema externo tenham trocado o nome de um campo;
  • Pode ser que eles tenham trocado o tipo do campo, de número para string (por exemplo), ou vice versa;
  • Pode ser que tenham removido o campo por completo;
  • Podem ter tornado o campo nullable ou optional (pode vir null ou undefined, respectivamente);
  • E por ai vai...

O ponto é que qualquer alteração no contrato do outro lado não será conhecida e nem alertada pelo TypeScript. E pode ser que você demore muito para descobrir que a sua tela parou de funcionar porque o time do backend resolveu trocar o formato de um campo de data de ISO string para unix epoch.

Mas, se não podemos conhecer ou prever as alterações em sistemas externos, o que podemos fazer para resolver ou atenuar essa situação? A resposta está em validar os dados recebidos assim que eles chegarem. Assim, seremos alertados sempre que recebermos objetos em formato diferente do esperado. E isso pode ser feito de diversas formas, desde uma solução manual, até a utilização de uma ferramenta, como o Zod.

A solução manual

Você pode criar uma solução de validação própria. Eu já fiz isso, mas descartei assim que descobri o Zod.

Por que? Porque o Zod é muito mais completo e testado do que as minhas tentativas exploratórias nesse sentido. E, além do mais, é leve e sem dependências. Portanto, não vi razão de continuar com a solução manual.

Zod: Uma solução mais prática e completa

O Zod é uma ferramenta que permite criar esquemas para as suas estruturas de dados. Esses esquemas podem ser utilizados tanto para validar os objetos em runtime quanto para inferir as interfaces TypeScript correspondentes.

Vamos ver como isso funciona na prática? É bem fácil.


import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

UserSchema.parse({ id: 5, name: "Silvester" });

type User = z.infer<typeof UserSchema>;

No exemplo acima, o z.infer<typeof UserSchema> gera o type User, um tipo TypeScript como outro qualquer. Já o UserSchema.parse(runtimeObj) é responsável por validar o objeto recebido em runtime de acordo com o esquema definido para ele.

Ok... validar não é exatamente a mesma coisa que "parsear", mas isso é assunto para outro momento.

Exemplo mais completo

O exemplo apresentado anteriormente serviu para ilustrar o funcionamento da biblioteca. Abaixo vou apresentar algo mais próximo de um cenário real.

Suponha que você queira consumir uma API externa e deseje validar os dados recebidos para garantir que estão no formato esperado.

Validação bruta (sem Zod)


type Person = {
  id: number,
  name: string,
  age: number,
  address: {
    streetName: string,
    zipCode: string,
  },
  nicknames: string[],
};

const response = fetch([ENDPOINT_URL]);

const person: Person = await response.json();

So far, so good. Agora começa a parte "feia".


if (!isNumber(person.id)) {
  throw new Error("Id field is not a number");
}

if (!isString(person.name)) {
  throw new Error("Name field is not a string");
}

if (!isNumber(person.age)) {
  throw new Error("Age field is not a number");
}

if (!person.address) {
  throw new Error("Address was not informed");
}

if (!isString(person.address.streetName)) {
  throw new Error("Address streetName is not a string");
}

if (!isString(person.address.zipCode)) {
  throw new Error("Address zipCode is not a string");
}

if (!isArray(person.nicknames)) {
  throw new Error("Nicknames filed is not an array");
}

Imagino que você nem tenha lido todas as validações acima. E olha que estamos usando um objeto simples. Agora vamos ver como o mesmo pode ser feito com o Zod.

Com Zod


import { z } from "zod";

const PersonSchema = z.object({
  id: z.number(),
  name: z.string(),
  age: z.number(),
  address: z.object({
    streetName: z.string(),
    zipCode: z.string(),
  }),
  nicknames: z.array(z.string()),
});

type Person = z.infer<typeof PersonSchema>;

const response = fetch([ENDPOINT_URL]);

const person: Person = await response.json();

Até aqui, não fez tanta diferença. Agora, a validação:


PersonSchema.parse(person);

Pronto. É isso. Em uma única linha, você validou toda a resposta. Talvez, no início, usar esquemas ao invés de declarar os tipos diretamente possa parecer um pouco confuso, mas tudo se paga no momento da validação.

O Zod apontou um erro. E agora?

O Zod consegue identificar divergências entre o objeto recebido e a interface esperada. Ele, inclusive, aponta qual campo está diferente. Contudo, o que fazer a partir daí é por sua conta.

Se, por um lado, deixar a aplicação crashar vá te fazer encontrar o erro mais rápido, por outro, você não vai querer que o seu cliente deixe de concluir uma compra porque o backend passou a retornar a sua idade em formato string ao invés de number.

Uma sugestão é: em ambiente de desenvolvimento, deixe o erro estourar. Desta forma, você vai identificar e corrigir rapidamente. Em produção, pode bastar um erro no console. Assim, se uma funcionalidade apresentar comportamento estranho, uma rápida passada pelos logs vai te permitir encontrar o motivo.

Conclusão

Já perdi a conta das vezes em que vi um time desperdiçar horas ou até dias tentando identificar a origem de um bug que "surgiu do nada", para depois descobrir que o problema está naquela response do backend que mudou de formato.

Você pode até esbravejar e mandar um e-mail para os responsáveis pela API, relatando sua indignação, mas isso não vai impedir que daqui a duas semanas outra response seja alterada.

O que você pode fazer é validar com frequência esses retornos, descobrindo divergências com agilidade e assertividade. Adicione o Zod ao seu sistema e pare de sofrer com mudanças não anunciadas em dependências externas.

Este post contém apenas uma introdução ao Zod, onde apresentei os conceitos e funcionalidades mais básicas da ferramenta. Sugiro que você dê uma olhada na documentação e explore a miríade de funcionalidades de validação e até mapeamento disponibilizados pela biblioteca.

Clique aqui para abrir o site oficial do Zod.

© 2023 Cartas de um Dev. Todos os Direitos Reservados.