Validação com Zod: schema, zodResolver, type-safety
5 min de leitura
No nó 2, a gente validou com opções do register:
required, pattern, minLength. Funciona, mas pra
form de produção você tem regras compostas ("senha
precisa de 8+ caracteres, com 1 número, 1 maiúscula"),
validação condicional ("se tipo = PJ, então CNPJ é
obrigatório"), mensagens de erro customizadas, e -
crucialmente - a mesma validação no frontend e no
backend. As opções do register não dão conta disso
com elegância.
O Zod entra aqui: é uma lib de schema declarativo em que você descreve a forma do dado válido, e ela deriva (a) um parser que valida em runtime, (b) o tipo TypeScript correspondente. Você escreve o schema uma vez e ganha os dois de graça.
O essencial 🟢
O schema é a fonte da verdade. Em vez de if/else
espalhado ou opções de register, você define o que
é válido uma vez:
import { z } from "zod";
const schema = z.object({
nome: z.string().min(1, "Nome é obrigatório"),
email: z.string().email("Email inválido"),
senha: z.string().min(8, "Senha precisa de 8+ caracteres"),
idade: z.number().int().min(18, "Idade mínima 18"),
aceitaTermos: z.literal(true, {
errorMap: () => ({ message: "Você precisa aceitar os termos" }),
}),
});
// Inferir o tipo TypeScript a partir do schema
type FormData = z.infer<typeof schema>;
// Equivale a:
// type FormData = {
// nome: string;
// email: string;
// senha: string;
// idade: number;
// aceitaTermos: true;
// }
z.infer<typeof schema> é o pulo do gato: o tipo do
form é derivado do schema. Se você adicionar um
campo no schema, o tipo atualiza sozinho. Se você
remover, some do tipo. Não tem como divergirem.
Validar manualmente (sem form) é útil pra teste. O
schema é uma função: dá pra chamar schema.parse(value)
e ela joga ZodError se inválido, ou devolve o valor
tipado se válido.
const resultado = schema.parse({
nome: "Ana",
email: "ana@x.com",
senha: "12345",
idade: 25,
aceitaTermos: true,
});
// resultado: { nome: "Ana", email: "...", senha: "...", idade: 25, aceitaTermos: true }
// Schema.parse joga ZodError se algo falhar
try {
schema.parse({ nome: "", email: "errado" });
} catch (e) {
if (e instanceof z.ZodError) {
console.log(e.issues);
// [{ path: ["nome"], message: "Nome é obrigatório" }, ...]
}
}
schema.safeParse(value) é a versão "não-joga" - devolve
{ success, data, error }. É o que zodResolver usa
internamente, e o que você usa quando quer tratar o erro
na mão (ex: teste, validação num server action).
zodResolver conecta Zod ao React Hook Form. O
pacote @hookform/resolvers/zod exporta um adapter
que o RHF entende. Passa o schema no useForm:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
nome: z.string().min(1, "Nome é obrigatório"),
email: z.string().email("Email inválido"),
senha: z.string().min(8, "Senha precisa de 8+ caracteres"),
});
type FormData = z.infer<typeof schema>;
function Cadastro() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
// Opcional: defaultValues tipados pelo schema
defaultValues: {
nome: "",
email: "",
senha: "",
},
});
const onSubmit = async (data: FormData) => {
// data é { nome, email, senha } - tipado pelo schema.
// Se chegar aqui, é porque passou em TODA a validação.
await fetch("/api/cadastro", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("nome")} aria-invalid={!!errors.nome} />
{errors.nome && <span role="alert">{errors.nome.message}</span>}
<input {...register("email")} aria-invalid={!!errors.email} />
{errors.email && <span role="alert">{errors.email.message}</span>}
<input type="password" {...register("senha")} aria-invalid={!!errors.senha} />
{errors.senha && <span role="alert">{errors.senha.message}</span>}
<button type="submit" disabled={isSubmitting}>Cadastrar</button>
</form>
);
}
Comparado com o nó 2, a diferença é:
- Sem
required/patternnoregister- a validação toda mora no schema. formState.errors.<campo>.messagejá vem em PT-BR (a string que você passou pro Zod).datanoonSubmité tipado pelo schema - sem generics manuais, semany, sem cast.
A anatomia do schema. Os métodos mais comuns do Zod que você vai usar:
- Primitivos:
z.string(),z.number(),z.boolean(),z.date(),z.bigint(),z.symbol(). - Validação por tipo:
.min(n),.max(n),.length(n),.email(),.url(),.uuid(),.regex(pattern),.int(),.positive(),.nonnegative(). - Opcional/null:
.optional()(pode serundefined),.nullable()(pode sernull),.nullish()(qualquer um dos dois). - Default:
.default(value)- se o campo não vier, usa o default. - Mensagem custom:
.min(1, "Mínimo 1")ou viaerrorMap. - Refinements:
.refine(fn, mensagem)- validação custom síncrona..superRefinepra múltiplos checks no mesmo campo. - Transform:
.transform(fn)- converte o valor depois de validar (string → number, string trimada, string lowercased).
const schema = z.object({
// String obrigatória com trim
nome: z.string().trim().min(1, "Obrigatório"),
// Email que aceita string vazia como undefined
email: z.string().email("Email inválido").optional(),
// Boolean que precisa ser true (ex: aceita termos)
aceitaTermos: z.literal(true, {
errorMap: () => ({ message: "Precisa aceitar" }),
}),
// String com regex custom
username: z
.string()
.min(3, "Mínimo 3")
.max(20, "Máximo 20")
.regex(/^[a-z0-9_]+$/, "Apenas letras minúsculas, números e _"),
// Refine custom (ex: senha forte)
senha: z
.string()
.min(8, "Mínimo 8 caracteres")
.refine((s) => /[A-Z]/.test(s), "Precisa de 1 maiúscula")
.refine((s) => /[0-9]/.test(s), "Precisa de 1 número"),
});
Schema para defaultValues: quando você define o
schema, o defaultValues precisa respeitar os campos
obrigatórios. Um truque comum é derivar o default
do schema com schema.partial() ou z.input:
// Pega os campos do schema como Partial<>
const defaults = schema.partial().parse({});
// defaults: { nome: undefined, email: undefined, ... }
// Ou define manualmente, mas tipando pelo schema
useForm<FormData>({
defaultValues: {
nome: "",
email: "",
senha: "",
} satisfies FormData,
});
Validação assíncrona com refine async. Pra
checar coisas externas (email já existe, CEP válido,
cartão real), o Zod suporta refine que retorna
Promise:
const schema = z.object({
email: z
.string()
.email("Email inválido")
.refine(
async (email) => {
const r = await fetch(`/api/usuarios/existe?email=${email}`);
const { existe } = await r.json();
return !existe;
},
{ message: "Email já cadastrado", path: ["email"] }
),
});
O RHF entende validação assíncrona e mostra "carregando"
via formState.isValidating (campo a campo) ou
formState.isSubmitting (form inteiro). Veremos
detalhes no nó 6 (validação no server).
safeParse vs parse. A diferença importa:
parse(value)- jogaZodErrorse falhar. Use em casos onde a falha é excepcional e você quer parar o fluxo (entrada de um job, request crítico).safeParse(value)- devolve{ success, data, error }. Use quando você quer tratar o erro programaticamente sem try/catch (e é o quezodResolverusa).
const result = schema.safeParse(dados);
if (result.success) {
// result.data é tipado como FormData
} else {
// result.error é ZodError com result.error.issues
}
Aprofundamento 🟡
Compor schemas: .merge, .extend, .pick,
.omit, .partial. Em forms grandes ou em sistemas
com formulários que compartilham campos, dá pra
compor schemas:
const baseAuth = z.object({
email: z.string().email(),
senha: z.string().min(8),
});
const cadastroSchema = baseAuth.extend({
nome: z.string().min(1),
confirmaSenha: z.string(),
}).refine((d) => d.senha === d.confirmaSenha, {
message: "Senhas não conferem",
path: ["confirmaSenha"],
});
const loginSchema = baseAuth; // reusa email+senha
// Pegar só um subconjunto
const emailSchema = baseAuth.pick({ email: true });
Validação condicional com superRefine ou
discriminatedUnion. Quando um campo depende do
valor de outro ("se tipo = PJ, então CNPJ"), use
superRefine no objeto inteiro:
const schema = z
.object({
tipo: z.enum(["PF", "PJ"]),
cpf: z.string().optional(),
cnpj: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.tipo === "PF" && !data.cpf) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["cpf"],
message: "CPF obrigatório para PF",
});
}
if (data.tipo === "PJ" && !data.cnpj) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["cnpj"],
message: "CNPJ obrigatório para PJ",
});
}
});
Ou, pra campos mutuamente exclusivos (só um dos
dois pode estar presente), use discriminatedUnion:
const schema = z.discriminatedUnion("tipo", [
z.object({ tipo: z.literal("PF"), cpf: z.string() }),
z.object({ tipo: z.literal("PJ"), cnpj: z.string() }),
]);
O discriminatedUnion é mais type-safe: o tipo de
data muda baseado em data.tipo.
z.coerce pra converter tipos automaticamente.
Inputs HTML sempre retornam string. Se o schema espera
number, dá pra converter antes de validar:
const schema = z.object({
idade: z.coerce.number().int().min(18),
// string do input -> vira number (ou joga erro se NaN)
});
z.coerce.number() é equivalente a
Number(value) antes de validar. Útil pra form de
input numérico onde o value é sempre string.
Reuso de schema no backend (preview do nó 6). O schema vive num arquivo que pode ser importado pelo front e pelo back. No Node:
// schema.ts (compartilhado)
export const cadastroSchema = z.object({ /* ... */ });
export type CadastroData = z.infer<typeof cadastroSchema>;
// server.ts
app.post("/api/cadastro", (req, res) => {
const result = cadastroSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
// result.data é CadastroData - tipado
salvarUsuario(result.data);
});
Isso é o "reuso de schema" do qual a trilha toda tira o nome. Veremos a fundo no nó 6.
safeParseAsync e validação que envolve I/O. Pra
checagens que dependem de banco/API externa, use
safeParseAsync (ou parseAsync):
const result = await schema.safeParseAsync(formData);
// result.success e result.error
O RHF com zodResolver(schema) detecta se o schema é
async automaticamente e mostra "carregando" via
isValidating. Sem código extra.
Pra quem quer ir além 🔴
Custom error maps pra i18n. Pra internacionalizar
mensagens de erro, Zod suporta z.setErrorMap:
z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.received === "undefined") return { message: "Campo obrigatório" };
}
return { message: ctx.defaultError };
});
Útil pra mensagens de erro em PT-BR / EN / ES
sem repetir a string em cada .min(1, "obrigatório").
Aprofundamento pertence à i18n-l10n (issue #53).
z.brand pra tipos nominais (TypeScript puro). Pra
impedir email: string de virar username: string
por engano, dá pra criar branded types:
const emailSchema = z.string().email().brand<"Email">();
type Email = z.infer<typeof emailSchema>;
function enviarEmail(email: Email) { /* ... */ }
enviarEmail("ana@x.com"); // ❌ TS error: string não é Email
enviarEmail(emailSchema.parse("ana@x.com")); // ✅
Padrão avançado, mas útil em apps grandes. Detalhes em
typescript-frontend (quando sair).
Zod vs Valibot em produção (2026). Valibot tem
a mesma API básica (object, string, email) mas
com funções em vez de método chaining, e tree-shaking
mais agressivo. Bundle: Valibot ~1 KB, Zod ~14 KB
gzipped. Vale considerar se você está publicando uma
lib ou tem budget apertado de bundle. Pra app
convencional, Zod ainda ganha pelo ecossistema.
Schema como documentação viva. Com z.toJSONSchema(),
você converte um schema Zod em JSON Schema (formato
OpenAPI) e gera documentação de API automaticamente.
Útil em times grandes ou em apps com doc pública.
Leitura recomendada:
- Zod - Getting Started - a doc oficial, com exemplos do zero.
- @hookform/resolvers - os adapters oficiais.
- Zod vs Valibot - comparativo direto, com números de bundle.
Dica: o ganho real de Zod não é "validação melhor" - é uma fonte da verdade. Você escreve o schema uma vez e tem o parser (runtime) + o tipo (compilador)
- as mensagens de erro + o reuso no server. Quando o tipo diverge do schema, o TS pega. Quando a regra muda, você muda num lugar só.
No próximo nó, vamos ver padrões de form que saem
do "1 input, 1 register" - field arrays pra listas
dinâmicas, campos condicionais, e quando forçar
controlled com useController.
// Quiz
Qual é a principal vantagem de derivar o tipo TypeScript do schema com `z.infer<typeof schema>`?