Validação de Formulários (Zod)
Padrões usados nos schemas Zod + React Hook Form em todo o app.
Cada feature com formulário tem um schemas/*.schema.ts com um schema Zod, plugado no
react-hook-form via zodResolver. Alguns padrões se repetem entre features — vale conhecer antes de
criar um schema novo.
Campo obrigatório condicional (.refine())
Quando um campo só é obrigatório dependendo de outro, usa .refine() no schema inteiro, apontando o erro
pro campo certo com path:
// candidate.schema.ts
.refine(
(data) => {
if (data.hasChildren && (data.childrenCount == null || data.childrenCount < 1)) return false;
return true;
},
{ message: 'Informe a quantidade de filhos', path: ['childrenCount'] },
)Validação cruzada de múltiplos campos (.superRefine())
Quando é preciso adicionar mais de um erro na mesma validação (ou a lógica é mais complexa que um
.refine() simples), usa .superRefine() com context.addIssue():
// client.schema.ts — CPF tem que ter 11 dígitos, CNPJ 14, dependendo do personType
const validateDocument = (data, ctx) => {
const digits = data.cnpjCpf.replace(/\D/g, '');
if (data.personType === CLIENT_PERSON_TYPE.PJ && digits.length !== 14) {
ctx.addIssue({ code: 'custom', message: 'CNPJ deve ter 14 dígitos', path: ['cnpjCpf'] });
}
if (data.personType === CLIENT_PERSON_TYPE.PF && digits.length !== 11) {
ctx.addIssue({ code: 'custom', message: 'CPF deve ter 11 dígitos', path: ['cnpjCpf'] });
}
};Reestruturar o payload antes de enviar (.transform())
O formulário de vaga (vacancy.schema.ts) é o exemplo mais elaborado: campos como coveragePeriod,
registration e completeName aparecem soltos no formulário (mais simples pro usuário preencher), mas o
backend espera eles agrupados dentro de details só quando o motive exigir (ver
Recrutamento no backend). O .superRefine() valida quais campos são
obrigatórios pra cada motive, e o .transform() logo depois reagrupa o payload final:
export const vacancySchema = baseVacancySchema
.superRefine((vacancy, context) => {
if ([ABSENCE, BREAK].includes(vacancy.motive) && !vacancy.coveragePeriod) {
context.addIssue({ code: 'custom', message: 'Preencha o prazo da cobertura', path: ['coveragePeriod'] });
}
// ...SUBSTITUTION exige registration + completeName
})
.transform((data) => {
const { coveragePeriod, registration, completeName, ...rest } = data;
// só inclui `details` se o motive exigir; senão retorna `rest` sem esses campos soltos
return requiresDetails ? { ...rest, details: { coveragePeriod, ... } } : rest;
});
export type CreateVacancyFormData = z.input<typeof vacancySchema>; // tipo ANTES do transform (o que o form usa)
export type SubmitVacancyPayload = z.output<typeof vacancySchema>; // tipo DEPOIS do transform (o que a API recebe)z.input<> vs z.output<> é o detalhe importante aqui: como o schema transforma o shape dos dados, o tipo
usado no useForm<> (input) é diferente do tipo enviado pro backend (output).
Schema de criação vs. edição
Formulários de edição costumam reaproveitar o schema base com .partial() (tudo opcional) e depois
.required({...}) só nos campos que continuam obrigatórios mesmo editando:
// vacancy.schema.ts
export const updateVacancySchema = baseVacancySchema.partial().required({
motive: true,
clientId: true,
state: true,
city: true,
// ...
});Upload de arquivo (imagem de roteiro da vaga)
Não é validado por Zod, mas segue o mesmo padrão de mutation dos formulários — vale registrar aqui por ser
o único upload de arquivo do app. useUploadRoadmapImage (features/vacancy/hooks/useUploadImage.tsx)
monta um FormData e faz PATCH multipart:
const formData = new FormData();
formData.append('roadmapImage', file);
await api.patch(`/vacancy/${vacancyId}/roadmap-image`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});No onSuccess, invalida só o vacancyQueryKeys.detail(vacancyId) — não a lista inteira, já que só aquele
registro mudou.