Os tipos TypeScript desaparecem na fronteira da rede. Em dispositivos móveis - conectividade instável, caches de CDN e implantações de backend que competem com o lançamento do seu aplicativo na loja - a validação em tempo de execução com Zod mantém os payloads da API honestos antes que cheguem à sua UI.
// lib/api/schemas.ts - formas compartilhadas para endpoints de lista + detalheimport { z } from "zod";export const ApiErrorSchema = z.object({ error: z.object({ code: z.string(), message: z.string(), }),});export const UserSummarySchema = z.object({ id: z.string(), name: z.string(), avatarUrl: z.string().url().nullable(),});export const UserDetailSchema = UserSummarySchema.extend({ email: z.string().email(), createdAt: z.string().datetime(),});export const UserListSchema = z.object({ items: z.array(UserSummarySchema), nextCursor: z.string().nullable(),});export type UserSummary = z.infer<typeof UserSummarySchema>;export type UserDetail = z.infer<typeof UserDetailSchema>;export type UserList = z.infer<typeof UserListSchema>;
// lib/api/client.ts - um helper de fetch validado para o aplicativoimport { ApiErrorSchema, UserDetailSchema, UserListSchema, type UserDetail, type UserList,} from "./schemas";const BASE = "https://api.example.com";async function readJson(res: Response): Promise<unknown> { const text = await res.text(); if (!text) return null; return JSON.parse(text) as unknown;}function throwApiError(status: number, body: unknown): never { const parsed = ApiErrorSchema.safeParse(body); if (parsed.success) { throw new Error(`${parsed.data.error.code}: ${parsed.data.error.message}`); } throw new Error(`HTTP ${status}`);}export async function fetchUserList(cursor?: string): Promise<UserList> { const url = new URL("/v1/users", BASE); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url); const json = await readJson(res); if (!res.ok) throwApiError(res.status, json); return UserListSchema.parse(json);}export async function fetchUserDetail(id: string): Promise<UserDetail> { const res = await fetch(`${BASE}/v1/users/${id}`); const json = await readJson(res); if (!res.ok) throwApiError(res.status, json); return UserDetailSchema.parse(json);}
fetch + res.json() retorna Promise<any> na biblioteca DOM do TypeScript - atribuir a uma interface nomeada não valida nada em tempo de execução.
Schemas Zod percorrem o objeto analisado e fazem coerção ou rejeitam campos; z.infer projeta o schema em um tipo TypeScript em tempo de compilação.
parse lança ZodError com um array de caminhos - útil em desenvolvimento para ver exatamente qual campo falhou.
safeParse retorna { success, data | error } - melhor para formulários e UI de nova tentativa inline onde você não quer exceções.
Em dispositivos móveis, falhas de validação geralmente significam cache obsoleto ou uma resposta parcial em uma conexão interrompida - registre o array issues do ZodError no seu relator de falhas.
import { z } from "zod";// Única fonte de verdade - exporta schema + tipo inferido juntosexport const DeviceSchema = z.object({ id: z.string(), name: z.string(), platform: z.enum(["ios", "android"]),});export type Device = z.infer<typeof DeviceSchema>;// Restringe unknown sem asserçãofunction isDevice(value: unknown): value is Device { return DeviceSchema.safeParse(value).success;}// Tipos de entrada vs saída ao usar .transform ou .defaulttype DeviceInput = z.input<typeof DeviceSchema>;type DeviceOutput = z.output<typeof DeviceSchema>;
Instale Zod com npx expo install zod para que a versão permaneça compatível com o lockfile do seu Expo SDK.
Mantenha os schemas em arquivos .ts simples - sem JSX - para que o Metro possa importá-los de hooks, tarefas em segundo plano e testes.
Prefira z.infer em vez de duplicar um interface Device - a interface divergirá na primeira vez que a API mudar.
Converter res.json() para uma interface - const data = (await res.json()) as User silencia o TypeScript, mas não payloads ruins. Correção: Atribua a unknown, depois UserSchema.parse(data).
Discrepância opcional vs anulável - O backend envia avatarUrl: null, mas o schema usa apenas .optional(); Zod rejeita a resposta. Correção: Use .nullable() para nulos SQL, .optional() para chaves ausentes, ou .nullish() para ambos.
Campos numéricos chegando como strings - Alguns gateways stringificam números em JSON. Correção:z.coerce.number() na fronteira, ou z.union([z.number(), z.string().transform(Number)]) quando ambas as formas aparecem.
Strings de data usadas como Date na UI - z.string().datetime() valida o formato, mas deixa uma string; new Date(iso) em cada tela duplica a lógica. Correção:.transform((s) => new Date(s)) uma vez no schema.
Validando apenas respostas de sucesso - Corpos de erro com uma forma diferente lançam ZodError opacos nos helpers de fetch. Correção: Ramifique em !res.ok, analise ApiErrorSchema primeiro, depois analise o schema de sucesso.
Respostas de lista enormes sem schema de paginação - Aceitar z.array(ItemSchema) quando a API adiciona { items, nextCursor } quebra todas as telas de uma vez. Correção: Modele o envelope explicitamente (UserListSchema acima).
Cache de JSON não validado no AsyncStorage - Uma versão antiga do aplicativo gravou uma forma que seu novo schema rejeita; os usuários entram em loop de falha ao iniciar. Correção: Versione as chaves de cache (users:v2) e execute safeParse na leitura; descarte em caso de falha.